""" Tattva.AI — Video Visualizer Generates annotated video overlays, suspicious frame galleries, and per-frame heatmaps for explainable video deepfake detection. """ from __future__ import annotations import cv2 import io import base64 import uuid import numpy as np from PIL import Image # Import the heatmap generator from existing visualizer from utils.visualizer import generate_heatmap_overlay def generate_video_forensics( video_path: str, frame_results: list, flagged_frames: list, max_suspicious: int = 8, ) -> dict: """ Generate comprehensive video forensic visualizations. Parameters ---------- video_path : str Path to the original video file. frame_results : list[dict] Per-frame detection results from video_detector. flagged_frames : list[int] Frame indices flagged as DEEPFAKE. max_suspicious : int Maximum number of suspicious frames to extract. Returns ------- dict with: suspicious_frames : list of frame data dicts with base64 images + heatmaps frame_confidence_timeline : list of {frame, timestamp, confidence, verdict} annotated_video_b64 : base64-encoded annotated MP4 (or None if generation fails) """ result = { "suspicious_frames": [], "frame_confidence_timeline": [], "annotated_video_b64": None, } # ── Build confidence timeline ───────────────────────── for fr in frame_results: fake_prob = fr.get("confidence", 50) verdict = fr.get("verdict", "UNKNOWN") # Normalize: for AUTHENTIC, confidence = "realness", we want "fakeness" if verdict == "AUTHENTIC": fake_score = 100 - fake_prob else: fake_score = fake_prob result["frame_confidence_timeline"].append({ "frame": fr.get("frame_index", 0), "timestamp": fr.get("timestamp", 0), "confidence": round(fake_score, 1), "verdict": verdict, }) # ── Extract suspicious frames with heatmaps ─────────── cap = cv2.VideoCapture(video_path) if not cap.isOpened(): return result # Collect suspicious frame indices (DEEPFAKE + SUSPICIOUS) suspicious_indices = [] for fr in frame_results: if fr.get("verdict") in ("DEEPFAKE", "SUSPICIOUS"): suspicious_indices.append(fr) # Sort by confidence (most suspicious first), limit count suspicious_indices.sort( key=lambda x: x.get("confidence", 0) if x.get("verdict") != "AUTHENTIC" else 0, reverse=True, ) suspicious_indices = suspicious_indices[:max_suspicious] for fr in suspicious_indices: fidx = fr.get("frame_index", 0) cap.set(cv2.CAP_PROP_POS_FRAMES, int(fidx)) ret, frame = cap.read() if not ret: continue rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) pil_image = Image.fromarray(rgb) # Generate base64 image img_b64 = _pil_to_b64(pil_image) # Generate heatmap overlay try: heatmap = generate_heatmap_overlay(pil_image) heatmap_b64 = _pil_to_b64(heatmap) except Exception: heatmap_b64 = None result["suspicious_frames"].append({ "frame_index": int(fidx), "timestamp": fr.get("timestamp", 0), "confidence": fr.get("confidence", 0), "verdict": fr.get("verdict", "UNKNOWN"), "image": img_b64, "heatmap": heatmap_b64, }) # ── Generate annotated video ────────────────────────── try: annotated_b64 = _generate_annotated_video(video_path, frame_results, cap) result["annotated_video_b64"] = annotated_b64 except Exception as e: print(f"[VideoVisualizer] Annotated video generation failed: {e}") cap.release() return result def _generate_annotated_video( video_path: str, frame_results: list, cap: cv2.VideoCapture ) -> str | None: """ Create an annotated version of the video with detection overlays. Returns base64-encoded MP4 or None on failure. """ import tempfile import os cap2 = cv2.VideoCapture(video_path) if not cap2.isOpened(): return None fps = cap2.get(cv2.CAP_PROP_FPS) or 30 width = int(cap2.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(cap2.get(cv2.CAP_PROP_FRAME_HEIGHT)) total_frames = int(cap2.get(cv2.CAP_PROP_FRAME_COUNT)) # Build a lookup of frame_index → result result_lookup = {} for fr in frame_results: result_lookup[fr.get("frame_index", -1)] = fr # Create temp output file import os tmp_path = os.path.join(tempfile.gettempdir(), f"annotated_{uuid.uuid4().hex}.mp4") # Try different codecs (avc1 is best for web, mp4v is safe fallback) codecs = ['avc1', 'mp4v', 'XVID'] out = None for codec in codecs: fourcc = cv2.VideoWriter_fourcc(*codec) out = cv2.VideoWriter(tmp_path, fourcc, fps, (width, height)) if out.isOpened(): break if not out or not out.isOpened(): cap2.release() return None # Limit frames for annotated video to prevent timeouts/gigantic files MAX_ANNOTATE_FRAMES = 150 total_to_process = min(total_frames, MAX_ANNOTATE_FRAMES) step = max(1, total_frames // MAX_ANNOTATE_FRAMES) # Find closest analyzed frame for each video frame analyzed_indices = sorted(result_lookup.keys()) # Color map for verdicts verdict_colors = { "DEEPFAKE": (0, 0, 255), # Red in BGR "SUSPICIOUS": (0, 200, 255), # Yellow-ish in BGR "AUTHENTIC": (0, 200, 0), # Green in BGR } processed_count = 0 for fidx in range(0, total_frames, step): if processed_count >= MAX_ANNOTATE_FRAMES: break cap2.set(cv2.CAP_PROP_POS_FRAMES, fidx) ret, frame = cap2.read() if not ret: break processed_count += 1 # Find the nearest analyzed frame closest = _find_nearest(analyzed_indices, fidx) fr_result = result_lookup.get(closest, None) if closest is not None else None if fr_result: verdict = fr_result.get("verdict", "UNKNOWN") confidence = fr_result.get("confidence", 0) color = verdict_colors.get(verdict, (128, 128, 128)) # Draw border cv2.rectangle(frame, (0, 0), (width - 1, height - 1), color, 3) # Draw top bar background overlay = frame.copy() cv2.rectangle(overlay, (0, 0), (width, 40), (0, 0, 0), -1) cv2.addWeighted(overlay, 0.7, frame, 0.3, 0, frame) # Draw verdict text label = f"{verdict} | {confidence:.1f}%" cv2.putText( frame, label, (10, 28), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2, cv2.LINE_AA, ) # Draw confidence bar bar_width = int((width - 20) * confidence / 100) cv2.rectangle(frame, (10, height - 20), (10 + bar_width, height - 10), color, -1) cv2.rectangle(frame, (10, height - 20), (width - 10, height - 10), (50, 50, 50), 1) out.write(frame) out.release() cap2.release() # Read the generated video and encode to base64 try: with open(tmp_path, "rb") as f: video_bytes = f.read() b64 = base64.b64encode(video_bytes).decode("utf-8") os.remove(tmp_path) # Only return if size is reasonable (<50MB base64) if len(b64) < 50 * 1024 * 1024: return f"data:video/mp4;base64,{b64}" return None except Exception: if os.path.exists(tmp_path): os.remove(tmp_path) return None def _find_nearest(sorted_indices: list, target: int) -> int | None: """Find the nearest value in a sorted list to the target.""" if not sorted_indices: return None pos = np.searchsorted(sorted_indices, target) if pos == 0: return sorted_indices[0] if pos == len(sorted_indices): return sorted_indices[-1] before = sorted_indices[pos - 1] after = sorted_indices[pos] return before if (target - before) <= (after - target) else after def _pil_to_b64(image: Image.Image, format: str = "PNG") -> str: """Convert a PIL Image to a base64 data URI string.""" buf = io.BytesIO() image.save(buf, format=format) buf.seek(0) b64 = base64.b64encode(buf.getvalue()).decode("utf-8") mime = "image/png" if format == "PNG" else "image/jpeg" return f"data:{mime};base64,{b64}"