Download stage2_explainability.py from BADRzs/deepguard: direct link, hf CLI and curl.
- Browser
- Download file 12.2 kB
-
https://huggingface.co/spaces/BADRzs/deepguard/resolve/main/stage2_explainability.py
- Command line
-
hf download hf://spaces/BADRzs/deepguard/stage2_explainability.py
-
curl -L -o stage2_explainability.py https://huggingface.co/spaces/BADRzs/deepguard/resolve/main/stage2_explainability.py
12.2 kB
| """ | |
| Stage 2 Explainability - Grad-CAM++ heatmaps + named artifact detection. | |
| Replaces generic Attention Rollout with decision-tied explanations. | |
| """ | |
| import numpy as np | |
| import torch | |
| import torch.nn.functional as F | |
| from PIL import Image | |
| from typing import List, Dict, Any, Optional, Tuple | |
| import cv2 | |
| from pytorch_grad_cam import GradCAMPlusPlus, GradCAM | |
| from pytorch_grad_cam.utils.image import show_cam_on_image | |
| from detectors.base import BaseDetector | |
| from detectors.frequency import FrequencyDetector | |
| from stage1_ensemble import EnsembleResult | |
| class _CamModelWrapper(torch.nn.Module): | |
| """Wraps HuggingFace model so forward returns logits Tensor instead of ModelOutput dict.""" | |
| def __init__(self, model): | |
| super().__init__() | |
| self.model = model | |
| def forward(self, x): | |
| out = self.model(x) | |
| if hasattr(out, 'logits'): | |
| return out.logits | |
| if isinstance(out, dict) and 'logits' in out: | |
| return out['logits'] | |
| return out | |
| def _vit_reshape_transform(tensor): | |
| """Reshape ViT token sequences [B, N, C] to 2D spatial maps [B, C, H, W].""" | |
| if tensor.ndim == 3: | |
| num_tokens = tensor.size(1) | |
| side = int(np.sqrt(num_tokens - 1)) | |
| if side * side == num_tokens - 1: | |
| tokens = tensor[:, 1:, :] | |
| else: | |
| side = int(np.sqrt(num_tokens)) | |
| tokens = tensor[:, :side * side, :] | |
| result = tokens.reshape(tensor.size(0), side, side, tensor.size(2)) | |
| return result.permute(0, 3, 1, 2) | |
| return tensor | |
| class _CamTarget: | |
| """Target function for Grad-CAM supporting single-logit binary and multi-class models.""" | |
| def __init__(self, target_class: int): | |
| self.target_class = target_class | |
| def __call__(self, model_output): | |
| s = model_output.squeeze() | |
| if s.ndim == 0: | |
| return s if self.target_class == 1 else -s | |
| if s.ndim == 1: | |
| if s.shape[0] == 1: | |
| return s[0] if self.target_class == 1 else -s[0] | |
| if self.target_class < s.shape[0]: | |
| return s[self.target_class] | |
| return s[0] | |
| return s[0, min(self.target_class, s.shape[-1] - 1)] | |
| def generate_gradcam_heatmap( | |
| image: Image.Image, | |
| detector: BaseDetector, | |
| target_class: int = 1, # 1 = FAKE class | |
| ) -> np.ndarray: | |
| """ | |
| Generate Grad-CAM / Grad-CAM++ heatmap for a detector's decision. | |
| Returns normalized 2D heatmap (H, W) in [0, 1]. | |
| """ | |
| target_layer = detector.get_gradcam_target_layer() | |
| if target_layer is None: | |
| return np.ones((14, 14)) * 0.5 | |
| # Preprocess image for the detector | |
| input_tensor = detector._preprocess(image) | |
| # Get the model with gradients enabled | |
| if hasattr(detector, '_get_model_for_cam'): | |
| model = detector._get_model_for_cam() | |
| elif hasattr(detector, '_model'): | |
| model = detector._model | |
| elif hasattr(detector, '_clip'): | |
| model = detector._clip | |
| elif hasattr(detector, '_siglip'): | |
| if hasattr(detector, '_get_model_for_cam'): | |
| model = detector._get_model_for_cam() | |
| else: | |
| model = detector._siglip | |
| else: | |
| return np.ones((14, 14)) * 0.5 | |
| was_training = model.training | |
| model.train() | |
| for param in model.parameters(): | |
| param.requires_grad = True | |
| wrapped_model = _CamModelWrapper(model) | |
| targets = [_CamTarget(target_class)] | |
| try: | |
| try: | |
| cam = GradCAM( | |
| model=wrapped_model, | |
| target_layers=[target_layer], | |
| reshape_transform=_vit_reshape_transform, | |
| ) | |
| grayscale_cam = cam(input_tensor=input_tensor, targets=targets) | |
| except Exception as cam_err: | |
| print(f"[Stage 2] GradCAM with reshape_transform failed: {cam_err}, trying direct...") | |
| cam = GradCAM( | |
| model=wrapped_model, | |
| target_layers=[target_layer], | |
| ) | |
| grayscale_cam = cam(input_tensor=input_tensor, targets=targets) | |
| if grayscale_cam.ndim == 3: | |
| grayscale_cam = grayscale_cam[0, :] | |
| finally: | |
| if not was_training: | |
| model.eval() | |
| return grayscale_cam | |
| def heatmap_to_overlay( | |
| cam: np.ndarray, | |
| original_image: Image.Image, | |
| alpha: float = 0.5, | |
| colormap: int = cv2.COLORMAP_JET, | |
| ) -> bytes: | |
| """Overlay heatmap on original image, return as PNG bytes.""" | |
| if cam.ndim == 3: | |
| cam = cam.squeeze(0) | |
| # Resize CAM to image size | |
| cam_resized = cv2.resize(cam, original_image.size, interpolation=cv2.INTER_LINEAR) | |
| # Normalize to [0, 1] | |
| if cam_resized.max() > cam_resized.min(): | |
| cam_resized = (cam_resized - cam_resized.min()) / (cam_resized.max() - cam_resized.min()) | |
| else: | |
| cam_resized = np.ones_like(cam_resized) * 0.5 | |
| # Apply colormap | |
| cam_uint8 = (cam_resized * 255).astype(np.uint8) | |
| heatmap_colored = cv2.applyColorMap(cam_uint8, colormap) | |
| heatmap_colored = cv2.cvtColor(heatmap_colored, cv2.COLOR_BGR2RGB) | |
| # Blend with original | |
| original_np = np.array(original_image.convert("RGB")) | |
| blended = cv2.addWeighted(original_np, 1 - alpha, heatmap_colored, alpha, 0) | |
| # Encode to PNG | |
| success, buffer = cv2.imencode('.png', cv2.cvtColor(blended, cv2.COLOR_RGB2BGR)) | |
| if not success: | |
| raise RuntimeError("Failed to encode heatmap overlay") | |
| return buffer.tobytes() | |
| def detect_blending_boundary( | |
| image: Image.Image, | |
| heatmap: np.ndarray, | |
| face_region: Optional[Tuple[int, int, int, int]] = None, | |
| ) -> Optional[Dict[str, Any]]: | |
| """ | |
| Detect face-swap blending boundary artifacts. | |
| Looks for sharp transitions in heatmap at face edges. | |
| """ | |
| if heatmap.ndim == 3: | |
| heatmap = heatmap.squeeze(0) | |
| # Convert heatmap to edges | |
| heatmap_uint8 = (heatmap * 255).astype(np.uint8) | |
| edges = cv2.Canny(heatmap_uint8, 50, 150) | |
| # Find contours | |
| contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) | |
| if not contours: | |
| return None | |
| # Filter contours near face region if provided | |
| suspicious_contours = [] | |
| h, w = heatmap.shape | |
| for cnt in contours: | |
| area = cv2.contourArea(cnt) | |
| if area < 50: # Too small | |
| continue | |
| x, y, cw, ch = cv2.boundingRect(cnt) | |
| # Check if contour aligns with face boundary | |
| if face_region: | |
| fx, fy, fw, fh = face_region | |
| # Check if contour overlaps face boundary | |
| if (x < fx + fw and x + cw > fx and | |
| y < fy + fh and y + ch > fy): | |
| suspicious_contours.append({ | |
| "bbox": [int(x), int(y), int(cw), int(ch)], | |
| "area": float(area), | |
| "type": "blending_boundary", | |
| }) | |
| else: | |
| # General suspicious edge | |
| suspicious_contours.append({ | |
| "bbox": [int(x), int(y), int(cw), int(ch)], | |
| "area": float(area), | |
| "type": "suspicious_edge", | |
| }) | |
| if not suspicious_contours: | |
| return None | |
| return { | |
| "type": "blending_boundary", | |
| "regions": suspicious_contours, | |
| "severity": min(len(suspicious_contours) / 5.0, 1.0), | |
| } | |
| def detect_ela_inconsistency(image: Image.Image, block_size: int = 8) -> Optional[Dict[str, Any]]: | |
| """ | |
| Error Level Analysis - detects JPEG recompression inconsistencies. | |
| Classic splicing indicator: different compression levels in different regions. | |
| """ | |
| # Save as JPEG at known quality, reload, compute difference | |
| import io | |
| # Original | |
| original = np.array(image.convert("RGB")) | |
| # Re-compress at quality 90 | |
| buf = io.BytesIO() | |
| image.save(buf, format='JPEG', quality=90) | |
| buf.seek(0) | |
| recompressed = np.array(Image.open(buf).convert("RGB")) | |
| # Difference | |
| diff = np.abs(original.astype(np.float32) - recompressed.astype(np.float32)) | |
| diff = diff.mean(axis=2) # Average across channels | |
| # Block-wise analysis | |
| h, w = diff.shape | |
| block_means = [] | |
| for y in range(0, h - block_size, block_size): | |
| for x in range(0, w - block_size, block_size): | |
| block = diff[y:y+block_size, x:x+block_size] | |
| block_means.append(block.mean()) | |
| block_means = np.array(block_means) | |
| if len(block_means) == 0: | |
| return None | |
| # Look for outliers (blocks with significantly different error levels) | |
| median_err = np.median(block_means) | |
| mad = np.median(np.abs(block_means - median_err)) | |
| # Modified z-score | |
| z_scores = 0.6745 * (block_means - median_err) / (mad + 1e-8) | |
| outliers = np.where(np.abs(z_scores) > 3.5)[0] | |
| if len(outliers) == 0: | |
| return None | |
| # Convert outlier indices to coordinates | |
| suspicious_blocks = [] | |
| for idx in outliers[:10]: # Limit to top 10 | |
| by = idx // ((w - block_size) // block_size) | |
| bx = idx % ((w - block_size) // block_size) | |
| suspicious_blocks.append({ | |
| "x": int(bx * block_size), | |
| "y": int(by * block_size), | |
| "size": block_size, | |
| "z_score": float(z_scores[idx]), | |
| }) | |
| return { | |
| "type": "ela_inconsistency", | |
| "suspicious_blocks": suspicious_blocks, | |
| "severity": min(len(outliers) / 20.0, 1.0), | |
| "description": f"Found {len(outliers)} blocks with inconsistent JPEG compression levels", | |
| } | |
| def detect_frequency_artifacts( | |
| image: Image.Image, | |
| frequency_detector: FrequencyDetector, | |
| ) -> List[Dict[str, Any]]: | |
| """Get artifacts from frequency domain analysis.""" | |
| return frequency_detector.get_artifacts(image) | |
| def run_stage2_explainability( | |
| image: Image.Image, | |
| ensemble_result: EnsembleResult, | |
| detectors: List[BaseDetector], | |
| mode: str, | |
| ) -> Dict[str, Any]: | |
| """ | |
| Run full Stage 2 explainability pipeline. | |
| Returns heatmap bytes and named artifact findings. | |
| """ | |
| # Use primary detector (highest weight) for Grad-CAM++ | |
| primary_detector = max(detectors, key=lambda d: d.model_name in ensemble_result.primary_model) | |
| # Target class: 1 for FAKE (assuming binary with FAKE=1) | |
| target_class = 1 if ensemble_result.ensemble_label == "FAKE" else 0 | |
| # Generate Grad-CAM++ heatmap | |
| try: | |
| cam = generate_gradcam_heatmap(image, primary_detector, target_class) | |
| except Exception as e: | |
| print(f"[Stage 2] Grad-CAM++ failed: {e}, using fallback") | |
| cam = np.ones((14, 14)) * 0.5 | |
| # Create overlay | |
| heatmap_bytes = heatmap_to_overlay(cam, image) | |
| # Detect named artifacts | |
| artifacts = [] | |
| # 1. Frequency artifacts (always run) | |
| freq_detector = next((d for d in detectors if isinstance(d, FrequencyDetector)), None) | |
| if freq_detector: | |
| freq_artifacts = detect_frequency_artifacts(image, freq_detector) | |
| artifacts.extend(freq_artifacts) | |
| # 2. Face-swap blending boundary (deepfake mode) | |
| if mode == "deepfake": | |
| # Try to get face region (simplified - would use face detector in production) | |
| blend_result = detect_blending_boundary(image, cam) | |
| if blend_result: | |
| artifacts.append(blend_result) | |
| # 3. ELA inconsistency (both modes) | |
| ela_result = detect_ela_inconsistency(image) | |
| if ela_result: | |
| artifacts.append(ela_result) | |
| # 4. High activation regions from heatmap | |
| high_activation = np.percentile(cam, 90) | |
| high_regions = np.argwhere(cam >= high_activation) | |
| if len(high_regions) > 0: | |
| artifacts.append({ | |
| "type": "high_model_attention", | |
| "description": f"Model focused on {len(high_regions)} regions with top 10% activation", | |
| "severity": float(np.mean(cam[cam >= high_activation])), | |
| "locations": high_regions.tolist()[:20], # Limit | |
| }) | |
| return { | |
| "heatmap_bytes": heatmap_bytes, | |
| "heatmap_array": cam, | |
| "artifacts": artifacts, | |
| "primary_detector": primary_detector.model_name, | |
| } |