Spaces:
Running
Running
Download app.py from Nas0x1/ai-image-forensics: direct link, hf CLI and curl.
- Browser
- Download file 18.8 kB
-
https://huggingface.co/spaces/Nas0x1/ai-image-forensics/resolve/main/app.py
- Command line
-
hf download hf://spaces/Nas0x1/ai-image-forensics/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Nas0x1/ai-image-forensics/resolve/main/app.py
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, | |
| ) | |