Spaces:
Sleeping
Sleeping
| """ | |
| Deepfake Hunter | |
| Lightweight media-forensics signal explorer for Hugging Face CPU Spaces. | |
| """ | |
| import os | |
| import sys | |
| from typing import Dict, Tuple | |
| import gradio as gr | |
| import numpy as np | |
| import plotly.graph_objects as go | |
| from PIL import Image, ImageDraw, ImageFilter | |
| sys.path.append(os.path.join(os.path.dirname(__file__), '..')) | |
| from shared.components import create_method_panel, create_premium_hero | |
| def normalize_image(image: np.ndarray) -> np.ndarray: | |
| arr = np.asarray(image).astype(np.float32) | |
| if arr.max() > 1: | |
| arr = arr / 255.0 | |
| if arr.ndim == 2: | |
| arr = np.stack([arr, arr, arr], axis=-1) | |
| return arr[:, :, :3] | |
| def compute_signal_scores(image: np.ndarray) -> Dict[str, float]: | |
| arr = normalize_image(image) | |
| gray = arr.mean(axis=2) | |
| # High-frequency residual approximates compression / synthesis artifacts. | |
| pil_gray = Image.fromarray((gray * 255).astype(np.uint8)) | |
| blurred = np.asarray(pil_gray.filter(ImageFilter.GaussianBlur(radius=1.5))).astype(np.float32) / 255.0 | |
| residual = np.abs(gray - blurred) | |
| artifact = float(np.clip(residual.mean() * 8.0, 0, 1)) | |
| # Channel disagreement can indicate odd blending or color-space artifacts. | |
| channel_gap = np.mean([ | |
| np.abs(arr[:, :, 0] - arr[:, :, 1]).mean(), | |
| np.abs(arr[:, :, 1] - arr[:, :, 2]).mean(), | |
| np.abs(arr[:, :, 0] - arr[:, :, 2]).mean(), | |
| ]) | |
| color_consistency = float(np.clip(channel_gap * 3.0, 0, 1)) | |
| # Edge irregularity uses simple gradients, avoiding OpenCV dependencies. | |
| gx = np.diff(gray, axis=1, append=gray[:, -1:]) | |
| gy = np.diff(gray, axis=0, append=gray[-1:, :]) | |
| edge_energy = np.sqrt(gx * gx + gy * gy) | |
| edge_irregularity = float(np.clip(edge_energy.std() * 5.0, 0, 1)) | |
| # Texture flatness catches over-smoothed generated faces/backgrounds. | |
| texture_flatness = float(np.clip(1.0 - gray.std() * 4.0, 0, 1)) | |
| return { | |
| "Artifact residual": artifact, | |
| "Color-channel mismatch": color_consistency, | |
| "Edge irregularity": edge_irregularity, | |
| "Texture flatness": texture_flatness, | |
| } | |
| def aggregate_score(scores: Dict[str, float], context: str) -> float: | |
| weights = { | |
| "Artifact residual": 0.34, | |
| "Color-channel mismatch": 0.22, | |
| "Edge irregularity": 0.24, | |
| "Texture flatness": 0.20, | |
| } | |
| score = sum(scores[key] * weight for key, weight in weights.items()) | |
| if context in {"Legal Evidence", "News Interview", "Political Speech"}: | |
| score = min(1.0, score * 1.08) | |
| return float(score) | |
| def create_gauge(score: float) -> go.Figure: | |
| if score < 0.32: | |
| color, label = "#15803d", "LOW SIGNAL" | |
| elif score < 0.62: | |
| color, label = "#e8935c", "REVIEW" | |
| else: | |
| color, label = "#991b1b", "HIGH SIGNAL" | |
| fig = go.Figure(go.Indicator( | |
| mode="gauge+number", | |
| value=score * 100, | |
| title={"text": label, "font": {"size": 22, "color": color}}, | |
| number={"suffix": "%", "font": {"size": 42, "color": color}}, | |
| gauge={ | |
| "axis": {"range": [0, 100]}, | |
| "bar": {"color": color}, | |
| "steps": [ | |
| {"range": [0, 32], "color": "#dcfce7"}, | |
| {"range": [32, 62], "color": "#ffedd5"}, | |
| {"range": [62, 100], "color": "#fee2e2"}, | |
| ], | |
| }, | |
| )) | |
| fig.update_layout(height=280, margin=dict(l=20, r=20, t=60, b=20), paper_bgcolor="white") | |
| return fig | |
| def create_breakdown(scores: Dict[str, float]) -> go.Figure: | |
| labels = list(scores.keys()) | |
| values = [scores[label] * 100 for label in labels] | |
| colors = ["#ffad7a", "#b8a9d9", "#7accff", "#e8935c"] | |
| fig = go.Figure(go.Bar( | |
| x=values, | |
| y=labels, | |
| orientation="h", | |
| marker=dict(color=colors), | |
| text=[f"{value:.1f}%" for value in values], | |
| textposition="outside", | |
| )) | |
| fig.update_layout( | |
| title="Detection Signal Breakdown", | |
| xaxis=dict(range=[0, 105], title="Signal strength"), | |
| height=280, | |
| margin=dict(l=20, r=20, t=50, b=20), | |
| paper_bgcolor="white", | |
| plot_bgcolor="white", | |
| ) | |
| return fig | |
| def annotate_image(image: np.ndarray, score: float) -> np.ndarray: | |
| arr = np.asarray(image).astype(np.uint8) | |
| pil = Image.fromarray(arr) | |
| draw = ImageDraw.Draw(pil) | |
| label = "Review suggested" if score >= 0.32 else "Low manipulation signal" | |
| color = (232, 147, 92) if score >= 0.32 else (21, 128, 61) | |
| draw.rounded_rectangle([14, 14, 300, 58], radius=8, fill=color) | |
| draw.text((26, 27), f"{label}: {score:.0%}", fill=(255, 255, 255)) | |
| return np.asarray(pil) | |
| def explain(scores: Dict[str, float], score: float, context: str) -> str: | |
| strongest = sorted(scores.items(), key=lambda item: item[1], reverse=True)[:2] | |
| parts = ", ".join(f"{name.lower()} ({value:.0%})" for name, value in strongest) | |
| if score < 0.32: | |
| verdict = "The image has low signal under these lightweight checks." | |
| elif score < 0.62: | |
| verdict = "The image deserves human review; signals are mixed rather than decisive." | |
| else: | |
| verdict = "The image shows elevated manipulation-like signals and should be reviewed carefully." | |
| return ( | |
| f"{verdict}\n\n" | |
| f"Strongest cues: {parts}.\n\n" | |
| f"Context: {context}. This Space is a signal explorer, not an authenticity oracle." | |
| ) | |
| def analyze_image(image: np.ndarray, context: str) -> Tuple[np.ndarray, go.Figure, go.Figure, str]: | |
| if image is None: | |
| empty = go.Figure() | |
| return None, empty, empty, "Upload an image to inspect manipulation signals." | |
| scores = compute_signal_scores(image) | |
| score = aggregate_score(scores, context) | |
| return annotate_image(image, score), create_gauge(score), create_breakdown(scores), explain(scores, score, context) | |
| with gr.Blocks(title="Deepfake Hunter", theme=gr.themes.Soft()) as app: | |
| create_premium_hero( | |
| "Deepfake Hunter", | |
| "Inspect image manipulation signals with visual overlays, confidence-style gauges, and explainable detection cues.", | |
| "🎭", | |
| badge="Media Forensics", | |
| highlights=["Signal breakdown", "Visual overlay", "Responsible AI framing"], | |
| ) | |
| create_method_panel({ | |
| "Technique": "Lightweight artifact, color, edge, and texture checks combined into an explainable score.", | |
| "What it proves": "Safety-sensitive AI should expose evidence and uncertainty instead of overconfident labels.", | |
| "Community value": "A teaching Space for media literacy and detection-model evaluation.", | |
| }) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| image_input = gr.Image(label="Upload Image", type="numpy") | |
| context_input = gr.Dropdown( | |
| ["General", "Political Speech", "News Interview", "Legal Evidence", "Entertainment", "Satire/Parody"], | |
| value="General", | |
| label="Review Context", | |
| ) | |
| analyze_btn = gr.Button("Analyze Image", variant="primary") | |
| with gr.Column(scale=1): | |
| image_output = gr.Image(label="Annotated Image") | |
| with gr.Row(): | |
| gauge_output = gr.Plot(label="Signal Gauge") | |
| breakdown_output = gr.Plot(label="Signal Breakdown") | |
| explanation_output = gr.Textbox(label="Explanation", lines=6) | |
| gr.Markdown(""" | |
| ## Reading The Output | |
| This Space is intentionally conservative. A high score means "inspect this carefully", not "this is definitely fake". | |
| The value is in the signal breakdown: it shows which visual cues contributed to the review recommendation. | |
| """) | |
| analyze_btn.click( | |
| analyze_image, | |
| inputs=[image_input, context_input], | |
| outputs=[image_output, gauge_output, breakdown_output, explanation_output], | |
| ) | |
| if __name__ == "__main__": | |
| app.launch(server_name="0.0.0.0", server_port=7860) | |