deepguard / stage2_explainability.py
BADRzs's picture
Upload folder using huggingface_hub
9dc05f8 verified
Raw History Blame Contribute Delete
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,
}