Nas0x1's picture
Deploy AI image forensics Space
a3eea95 verified
Raw History Blame Contribute Delete
18.8 kB
from __future__ import annotations
import os
import warnings
from typing import Any
os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
warnings.filterwarnings(
"ignore",
message=r"The 'css' parameter in the Blocks constructor will be removed in Gradio 6\.0.*",
category=DeprecationWarning,
)
import gradio as gr
import pandas as pd
from PIL import Image
from core.cache import GLOBAL_CACHE
from core.fusion import fuse_results
from core.image_utils import extract_exif, normalize_for_processing, open_image_safely
from core.photographic_features import analyze_photographic_signals
from core.report import build_report
from core.safety import limitation_text, safe_filename
from detectors.registry import get_detectors
from forensic.ela import generate_ela
from forensic.heatmap import generate_heatmap
from forensic.image_blend import blend_overlay
from forensic.noise import generate_noise_residual
from forensic.patch_map import generate_patch_map
from forensic.spectrum import generate_frequency_spectrum
APP_TITLE = "AI Image Forensics — Multi-Model Ensemble"
ANALYSIS_VERSION = "2026-06-05-mixed-ml-v2"
OVERLAY_MODES = [
"Original",
"Heatmap",
"CLIP Model Heatmap",
"ViT Model Heatmap",
"EfficientNet Model Heatmap",
"Dima Model Heatmap",
"NYUAD Model Heatmap",
"SMOGY Model Heatmap",
"SDXL Model Heatmap",
"AIRealNet Model Heatmap",
"ELA",
"Noise Residual",
"Frequency Spectrum",
"Patch Map",
]
def _percent(value: float) -> str:
return f"{float(value) * 100:.1f}%"
def _verdict_html(fused: dict) -> str:
verdict = fused.get("verdict", "INCONCLUSIVE")
status_class = {
"AI GENERATED": "verdict-ai",
"AI GENERATED - MIXED SIGNALS": "verdict-ai",
"REAL": "verdict-real",
"MANIPULATED / EDITED": "verdict-manipulated",
"INCONCLUSIVE": "verdict-inconclusive",
}.get(verdict, "verdict-inconclusive")
return f"""
<div class="verdict-card {status_class}">
<div class="eyebrow">Final verdict</div>
<div class="verdict-text">{verdict}</div>
<div class="summary">{fused.get("summary", "")}</div>
</div>
"""
def _metric_html(label: str, value: float) -> str:
pct = max(0.0, min(100.0, float(value) * 100))
return f"""
<div class="metric-card">
<div class="metric-row"><span>{label}</span><strong>{pct:.1f}%</strong></div>
<div class="meter"><div style="width:{pct:.1f}%"></div></div>
</div>
"""
def _metadata_summary(exif: dict[str, Any], photographic: dict[str, Any] | None = None) -> dict[str, Any]:
photographic = photographic or {}
camera_analysis = photographic.get("camera", {})
lighting_analysis = photographic.get("lighting", {})
camera = " ".join(str(exif.get(key, "")) for key in ("Make", "Model")).strip() or "Unknown"
focal_length = camera_analysis.get("focal_length")
focal_length_35mm = camera_analysis.get("focal_length_35mm")
f_number = camera_analysis.get("f_number")
exposure_time = camera_analysis.get("exposure_time")
iso = camera_analysis.get("iso")
return {
"exif_present": bool(exif),
"camera": camera,
"lens": camera_analysis.get("lens", "Unknown"),
"focal_length": focal_length if focal_length is not None else "Unknown",
"focal_length_35mm": focal_length_35mm if focal_length_35mm is not None else "Unknown",
"f_number": f_number if f_number is not None else "Unknown",
"exposure_time": exposure_time if exposure_time is not None else "Unknown",
"iso": iso if iso is not None else "Unknown",
"camera_authenticity_score": camera_analysis.get("camera_authenticity_score", 0.35),
"focal_consistency_score": camera_analysis.get("focal_consistency_score", 0.35),
"lighting_consistency_score": lighting_analysis.get("lighting_consistency_score", 0.35),
"shadow_consistency_score": lighting_analysis.get("shadow_consistency_score", 0.35),
"lighting_suspicion_score": lighting_analysis.get("lighting_suspicion_score", 0.65),
"software": str(exif.get("Software", "Unknown")) if exif else "Unknown",
"date": str(exif.get("DateTimeOriginal") or exif.get("DateTime") or "Unknown") if exif else "Unknown",
}
def _metadata_markdown(metadata: dict[str, Any], warnings: list[str]) -> str:
warning_lines = "\n".join(f"- {item}" for item in warnings[:8]) if warnings else "- No metadata warnings."
return (
f"**EXIF present:** {'Yes' if metadata.get('exif_present') else 'No'}\n\n"
f"**Camera make/model:** {metadata.get('camera', 'Unknown')}\n\n"
f"**Lens:** {metadata.get('lens', 'Unknown')}\n\n"
f"**Focal length:** {metadata.get('focal_length', 'Unknown')}\n\n"
f"**Camera signal:** {_percent(metadata.get('camera_authenticity_score', 0.0))}\n\n"
f"**Focal consistency:** {_percent(metadata.get('focal_consistency_score', 0.0))}\n\n"
f"**Shadow/light consistency:** {_percent(metadata.get('lighting_consistency_score', 0.0))}\n\n"
f"**Software:** {metadata.get('software', 'Unknown')}\n\n"
f"**Date:** {metadata.get('date', 'Unknown')}\n\n"
f"**Warnings**\n{warning_lines}"
)
def _detector_table(fused: dict) -> pd.DataFrame:
rows = []
for item in fused.get("detectors", []):
rows.append(
{
"Detector": item["name"],
"Category": item["category"],
"AI probability": _percent(item["ai_probability"]),
"Manipulation probability": _percent(item["manipulation_probability"]),
"Confidence": _percent(item["confidence"]),
"Weight": item["weight"],
"Status": item["status"],
"Explanation": item["explanation"],
}
)
return pd.DataFrame(rows)
def _collect_maps(processing_image: Image.Image, results: list) -> dict[str, Image.Image]:
artifacts: dict[str, Any] = {}
for result in results:
artifacts.update(result.artifacts)
ela = artifacts.get("ela") or generate_ela(processing_image)
noise = artifacts.get("noise") or generate_noise_residual(processing_image)
spectrum = artifacts.get("spectrum") or generate_frequency_spectrum(processing_image)
patch = artifacts.get("patch_map") or generate_patch_map(processing_image)
heatmap = generate_heatmap(processing_image, patch)
return {
"Original": processing_image,
"Heatmap": heatmap,
"CLIP Model Heatmap": artifacts.get("clip_heatmap"),
"ViT Model Heatmap": artifacts.get("vit_heatmap"),
"EfficientNet Model Heatmap": artifacts.get("efficientnet_heatmap"),
"Dima Model Heatmap": artifacts.get("dima_heatmap"),
"NYUAD Model Heatmap": artifacts.get("nyuad_heatmap"),
"SMOGY Model Heatmap": artifacts.get("smogy_heatmap"),
"SDXL Model Heatmap": artifacts.get("organika_sdxl_heatmap"),
"AIRealNet Model Heatmap": artifacts.get("airealnet_heatmap"),
"ELA": ela,
"Noise Residual": noise,
"Frequency Spectrum": spectrum,
"Patch Map": patch,
}
def _render_overlay(state: dict | None, mode: str, opacity: float) -> Image.Image | None:
if not state:
return None
maps = state.get("maps", {})
original = maps.get("Original")
selected = maps.get(mode)
if original is None or selected is None:
return original
if mode == "Original":
return original
if mode == "Frequency Spectrum":
return selected
return blend_overlay(original, selected, opacity)
def update_overlay(state, mode, opacity):
return _render_overlay(state, mode, opacity)
def analyze_image(file_path):
if not file_path:
empty = pd.DataFrame()
return (
"<div class='verdict-card verdict-inconclusive'><div class='verdict-text'>Upload an image</div></div>",
_metric_html("AI probability", 0.0),
_metric_html("Manipulation probability", 0.0),
_metric_html("Confidence", 0.0),
_metric_html("Model agreement", 0.0),
_metric_html("ML consensus", 0.0),
_metric_html("Uncertainty", 1.0),
_metric_html("Camera signal", 0.0),
_metric_html("Shadow/light consistency", 0.0),
None,
empty,
"Upload a supported image to inspect metadata.",
limitation_text(),
None,
)
try:
original_image, raw_bytes, fmt = open_image_safely(file_path)
cache_key = GLOBAL_CACHE.hash_bytes(raw_bytes + ANALYSIS_VERSION.encode("utf-8"))
cached = GLOBAL_CACHE.get(cache_key)
if cached:
state = cached["state"]
fused = cached["fused"]
metadata = cached["metadata"]
return (
_verdict_html(fused),
_metric_html("AI probability", fused["ai_probability"]),
_metric_html("Manipulation probability", fused["manipulation_probability"]),
_metric_html("Confidence", fused["confidence"]),
_metric_html("Model agreement", fused["model_agreement"]),
_metric_html("ML consensus", fused.get("ml_ai_probability") or 0.0),
_metric_html("Uncertainty", fused["uncertainty"]),
_metric_html("Camera signal", metadata.get("camera_authenticity_score", 0.0)),
_metric_html("Shadow/light consistency", metadata.get("lighting_consistency_score", 0.0)),
_render_overlay(state, "Heatmap", 0.55),
_detector_table(fused),
_metadata_markdown(metadata, fused.get("warnings", [])),
build_report(fused, metadata),
state,
)
processing_image = normalize_for_processing(original_image)
exif = extract_exif(original_image)
photographic = analyze_photographic_signals(processing_image, exif)
metadata = _metadata_summary(exif, photographic)
context = {
"filename": safe_filename(file_path),
"format": fmt,
"original_image": original_image,
"original_bytes": raw_bytes,
"exif": exif,
"metadata": metadata,
"photographic": photographic,
}
results = []
for detector in get_detectors():
try:
results.append(detector.analyze(processing_image, context))
except Exception as exc:
results.append(detector.error_result(exc))
fused = fuse_results(results, context)
maps = _collect_maps(processing_image, results)
state = {"maps": maps, "fused": fused, "metadata": metadata}
GLOBAL_CACHE.set(cache_key, {"state": state, "fused": fused, "metadata": metadata})
return (
_verdict_html(fused),
_metric_html("AI probability", fused["ai_probability"]),
_metric_html("Manipulation probability", fused["manipulation_probability"]),
_metric_html("Confidence", fused["confidence"]),
_metric_html("Model agreement", fused["model_agreement"]),
_metric_html("ML consensus", fused.get("ml_ai_probability") or 0.0),
_metric_html("Uncertainty", fused["uncertainty"]),
_metric_html("Camera signal", metadata.get("camera_authenticity_score", 0.0)),
_metric_html("Shadow/light consistency", metadata.get("lighting_consistency_score", 0.0)),
_render_overlay(state, "Heatmap", 0.55),
_detector_table(fused),
_metadata_markdown(metadata, fused.get("warnings", [])),
build_report(fused, metadata),
state,
)
except Exception as exc:
fused = {
"verdict": "INCONCLUSIVE",
"summary": f"Image could not be analyzed: {exc}",
"ai_probability": 0.5,
"manipulation_probability": 0.5,
"confidence": 0.0,
"model_agreement": 0.0,
"uncertainty": 1.0,
"detectors": [],
"warnings": [str(exc)],
}
return (
_verdict_html(fused),
_metric_html("AI probability", 0.5),
_metric_html("Manipulation probability", 0.5),
_metric_html("Confidence", 0.0),
_metric_html("Model agreement", 0.0),
_metric_html("ML consensus", 0.0),
_metric_html("Uncertainty", 1.0),
_metric_html("Camera signal", 0.0),
_metric_html("Shadow/light consistency", 0.0),
None,
pd.DataFrame(),
f"Analysis failed safely: {exc}",
limitation_text(),
None,
)
CSS = """
:root {
--bg: #090a12;
--panel: #111320;
--panel-2: #151827;
--border: #282d46;
--text: #f4f5fb;
--muted: #a8adc4;
--accent: #8b5cf6;
--accent-2: #6366f1;
}
body, .gradio-container {
background: radial-gradient(circle at top left, rgba(99, 102, 241, 0.16), transparent 30%), var(--bg) !important;
color: var(--text) !important;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.app-shell {max-width: 1360px; margin: 0 auto;}
.hero {
padding: 28px 8px 16px;
border-bottom: 1px solid rgba(139, 92, 246, 0.22);
margin-bottom: 18px;
}
.hero h1 {font-size: 34px; line-height: 1.1; margin: 0 0 8px; letter-spacing: 0;}
.hero p {color: var(--muted); font-size: 16px; margin: 0; max-width: 820px;}
.panel, .metric-card, .verdict-card {
background: linear-gradient(180deg, rgba(21, 24, 39, 0.98), rgba(13, 15, 26, 0.98));
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.24);
}
.verdict-card {padding: 20px; min-height: 148px;}
.eyebrow {text-transform: uppercase; color: var(--muted); font-size: 12px; letter-spacing: .08em; margin-bottom: 8px;}
.verdict-text {font-size: 30px; font-weight: 800; letter-spacing: 0; margin-bottom: 8px;}
.summary {color: var(--muted); font-size: 14px; line-height: 1.5;}
.verdict-ai .verdict-text {color: #f59e0b;}
.verdict-real .verdict-text {color: #34d399;}
.verdict-manipulated .verdict-text {color: #fb7185;}
.verdict-inconclusive .verdict-text {color: #c4b5fd;}
.metric-card {padding: 14px 15px; margin: 8px 0;}
.metric-row {display: flex; justify-content: space-between; gap: 12px; color: var(--muted);}
.metric-row strong {color: var(--text);}
.meter {height: 8px; background: #272b42; border-radius: 999px; overflow: hidden; margin-top: 10px;}
.meter div {height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent-2)); border-radius: 999px;}
.disclaimer {color: var(--muted); font-size: 13px; line-height: 1.45;}
.gradio-container .prose, .gradio-container p, .gradio-container label {color: var(--text);}
button.primary {background: linear-gradient(90deg, var(--accent), var(--accent-2)) !important; border: 0 !important;}
"""
with gr.Blocks(css=CSS, title=APP_TITLE) as demo:
analysis_state = gr.State(None)
with gr.Column(elem_classes=["app-shell"]):
gr.HTML(
f"""
<div class="hero">
<h1>{APP_TITLE}</h1>
<p>Upload an image to estimate whether it is real, AI-generated, manipulated, or inconclusive.</p>
</div>
"""
)
with gr.Row(equal_height=False):
with gr.Column(scale=4):
with gr.Group(elem_classes=["panel"]):
upload = gr.Image(
label="Image upload",
sources=["upload"],
type="filepath",
image_mode="RGB",
height=360,
)
analyze_btn = gr.Button("Analyze image", variant="primary")
gr.Markdown(
f"<div class='disclaimer'>{limitation_text()}</div>",
show_label=False,
)
with gr.Column(scale=5):
verdict = gr.HTML()
with gr.Row():
ai_metric = gr.HTML()
manipulation_metric = gr.HTML()
with gr.Row():
confidence_metric = gr.HTML()
agreement_metric = gr.HTML()
ml_consensus_metric = gr.HTML()
with gr.Row():
uncertainty_metric = gr.HTML()
camera_metric = gr.HTML()
lighting_metric = gr.HTML()
with gr.Row(equal_height=False):
with gr.Column(scale=6):
gr.Markdown("### Forensic viewer")
overlay_image = gr.Image(
label="Suspicious-region visualization",
type="pil",
height=520,
)
with gr.Row():
overlay_mode = gr.Radio(OVERLAY_MODES, value="Heatmap", label="Overlay")
overlay_opacity = gr.Slider(0.0, 1.0, value=0.55, step=0.05, label="Opacity")
with gr.Column(scale=4):
gr.Markdown("### Metadata panel")
metadata_panel = gr.Markdown()
gr.Markdown("### Report")
report_panel = gr.Markdown()
gr.Markdown("### Detector table")
detector_table = gr.DataFrame(
headers=[
"Detector",
"Category",
"AI probability",
"Manipulation probability",
"Confidence",
"Weight",
"Status",
"Explanation",
],
wrap=True,
interactive=False,
)
analyze_btn.click(
analyze_image,
inputs=[upload],
outputs=[
verdict,
ai_metric,
manipulation_metric,
confidence_metric,
agreement_metric,
ml_consensus_metric,
uncertainty_metric,
camera_metric,
lighting_metric,
overlay_image,
detector_table,
metadata_panel,
report_panel,
analysis_state,
],
)
overlay_mode.change(update_overlay, inputs=[analysis_state, overlay_mode, overlay_opacity], outputs=[overlay_image])
overlay_opacity.change(update_overlay, inputs=[analysis_state, overlay_mode, overlay_opacity], outputs=[overlay_image])
if __name__ == "__main__":
demo.launch(
server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
server_port=int(os.getenv("PORT", "7860")),
ssr_mode=False,
)