intrusionx-backend / utils /video_visualizer.py
ayucancode's picture
Upload 18 files
56c08f3 verified
Raw
History Blame Contribute Delete
8.82 kB
"""
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}"