""" SuS Meter — AI Image Authenticity Detector Layers: 1. Metadata / EXIF analysis 2. Pixel-level statistical anomaly detection (FFT + noise) 3. Hidden watermark layer — C2PA manifest read (hard signal) + FFT-based SynthID-likelihood heuristic (soft signal) """ import gradio as gr import numpy as np from PIL import Image, ExifTags import io import tempfile import os try: import c2pa C2PA_AVAILABLE = True except Exception: C2PA_AVAILABLE = False # ----------------------------- Layer 1: Metadata ----------------------------- def check_metadata(pil_image, file_path): score = 0 reasons = [] try: exif_data = pil_image._getexif() except Exception: exif_data = None if not exif_data: score += 25 reasons.append(("⚠️", "No EXIF metadata found — real camera photos almost always carry EXIF; AI generators typically strip or never write it")) else: tags = {ExifTags.TAGS.get(k, k): v for k, v in exif_data.items()} has_camera_info = any(k in tags for k in ["Make", "Model", "LensModel"]) if has_camera_info: score -= 15 reasons.append(("✅", f"Camera metadata present ({tags.get('Make', '?')} {tags.get('Model', '')}) — signal of a real photograph")) else: score += 10 reasons.append(("⚠️", "EXIF exists but no camera make/model — inconsistent with a genuine photo")) # Software tag often left by editors/generators if exif_data: tags = {ExifTags.TAGS.get(k, k): v for k, v in exif_data.items()} software = str(tags.get("Software", "")).lower() ai_tools = ["stable diffusion", "midjourney", "dall-e", "dalle", "comfyui", "automatic1111", "flux", "leonardo"] for tool in ai_tools: if tool in software: score += 40 reasons.append(("🚨", f"Software tag reveals AI generator: \"{tags.get('Software')}\"")) break return score, reasons # ----------------------------- Layer 2: Pixel/statistical anomalies ----------------------------- def fft_periodicity_score(gray_arr): """Diffusion/GAN upsampling often leaves periodic grid artifacts visible as bright peaks in the frequency spectrum away from the DC center.""" f = np.fft.fft2(gray_arr) fshift = np.fft.fftshift(f) magnitude = np.log1p(np.abs(fshift)) h, w = magnitude.shape cy, cx = h // 2, w // 2 # Zero out the center (DC + low freq, always bright naturally) r = min(h, w) // 12 masked = magnitude.copy() masked[cy - r:cy + r, cx - r:cx + r] = 0 # Look for unusually strong, sparse peaks -> periodic grid signature threshold = masked.mean() + 4 * masked.std() peak_ratio = float((masked > threshold).sum()) / masked.size return peak_ratio, magnitude def noise_consistency_score(gray_arr): """Real camera sensor noise is chaotic and roughly uniform in local variance across the image. AI images often have unnaturally smooth or inconsistent noise patterns block-to-block.""" h, w = gray_arr.shape block = 16 variances = [] for y in range(0, h - block, block): for x in range(0, w - block, block): patch = gray_arr[y:y + block, x:x + block] variances.append(np.var(patch)) variances = np.array(variances) if len(variances) == 0: return 0.0 # Coefficient of variation of local noise variance cv = float(np.std(variances) / (np.mean(variances) + 1e-6)) return cv def check_pixel_anomalies(pil_image): score = 0 reasons = [] img = pil_image.convert("L") # Downscale huge images for speed img.thumbnail((1024, 1024)) arr = np.array(img).astype(np.float32) peak_ratio, _ = fft_periodicity_score(arr) if peak_ratio > 0.0015: score += 20 reasons.append(("⚠️", f"Frequency spectrum shows sparse high-energy peaks (ratio {peak_ratio:.4f}) — consistent with generator upsampling artifacts")) else: reasons.append(("✅", "No strong periodic grid artifacts detected in frequency spectrum")) cv = noise_consistency_score(arr) if cv < 0.35: score += 20 reasons.append(("⚠️", f"Local noise variance unusually uniform (CV {cv:.2f}) — real sensor noise is typically more chaotic")) else: reasons.append(("✅", f"Noise pattern variance looks natural (CV {cv:.2f})")) return score, reasons # ----------------------------- Layer 3: Hidden watermark ----------------------------- def check_c2pa(file_path): """Hard signal: read the actual embedded C2PA Content Credentials manifest if present. This is an open standard now used by both Google (Gemini/Imagen) and OpenAI (ChatGPT/API/Codex) images.""" if not C2PA_AVAILABLE: return 0, [("ℹ️", "C2PA library unavailable in this environment — skipped")] try: reader = c2pa.Reader(file_path) manifest_json = reader.json() if manifest_json and manifest_json.strip() and manifest_json.strip() != "null": return 60, [("🚨", "C2PA Content Credentials manifest found — file explicitly declares AI-generation provenance")] else: return 0, [("✅", "No C2PA manifest found")] except Exception: return 0, [("✅", "No C2PA manifest detected in this file")] def synthid_likelihood_heuristic(pil_image): """Soft signal, NOT a real SynthID decode (that requires Google's private key). This approximates the idea behind the open-source reverse-SynthID project: look for the kind of structured, low-amplitude carrier-frequency pattern spread across the spectrum that neural watermarking embeds, distinct from natural JPEG/sensor noise.""" img = pil_image.convert("L") img.thumbnail((512, 512)) arr = np.array(img).astype(np.float32) f = np.fft.fft2(arr) fshift = np.fft.fftshift(f) magnitude = np.abs(fshift) h, w = magnitude.shape cy, cx = h // 2, w // 2 r_inner = min(h, w) // 6 r_outer = min(h, w) // 2 - 2 y, x = np.ogrid[:h, :w] dist = np.sqrt((y - cy) ** 2 + (x - cx) ** 2) ring_mask = (dist >= r_inner) & (dist <= r_outer) ring_vals = magnitude[ring_mask] if len(ring_vals) == 0: return 0.0 # Structured watermark energy tends to raise the "flatness"/uniformity # of energy spread across the mid-frequency ring vs a natural falloff ring_energy = float(np.mean(ring_vals)) total_energy = float(np.mean(magnitude) + 1e-6) ratio = ring_energy / total_energy return ratio def check_hidden_watermark(pil_image, file_path): score = 0 reasons = [] c2pa_score, c2pa_reasons = check_c2pa(file_path) score += c2pa_score reasons.extend(c2pa_reasons) ratio = synthid_likelihood_heuristic(pil_image) if ratio > 0.9: score += 15 reasons.append(("⚠️", f"Mid-frequency spectral energy unusually elevated (ratio {ratio:.2f}) — weak independent signal of embedded watermarking. Not a cryptographic SynthID confirmation.")) else: reasons.append(("ℹ️", f"Mid-frequency spectral profile within normal range (ratio {ratio:.2f})")) return score, reasons # ----------------------------- Combine ----------------------------- def analyze_image(image): if image is None: return "Upload an image first.", "", "" pil_image = Image.open(image) if isinstance(image, str) else image file_path = image if isinstance(image, str) else None if file_path is None: tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False) pil_image.save(tmp.name) file_path = tmp.name total_score = 0 all_reasons = [] s1, r1 = check_metadata(pil_image, file_path) total_score += s1 all_reasons.append(("Layer 1 — Metadata & EXIF", r1)) s2, r2 = check_pixel_anomalies(pil_image) total_score += s2 all_reasons.append(("Layer 2 — Pixel & Frequency Analysis", r2)) s3, r3 = check_hidden_watermark(pil_image, file_path) total_score += s3 all_reasons.append(("Layer 3 — Hidden Watermark / Provenance", r3)) total_score = max(0, min(100, total_score)) if total_score >= 60: verdict = "🚨 LIKELY AI-GENERATED" color = "#39ff6a" elif total_score >= 30: verdict = "⚠️ POSSIBLY AI-GENERATED" color = "#39ff6a" else: verdict = "✅ LIKELY AUTHENTIC" color = "#39ff6a" gauge_html = f"""
SuS Meter
{total_score}%
{verdict}
""" details_html = "" for layer_name, reasons in all_reasons: details_html += f'
{layer_name}
' for icon, text in reasons: details_html += f'
{icon}{text}
' details_html += "
" return gauge_html, details_html # ----------------------------- Styling ----------------------------- CUSTOM_CSS = """ @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap'); .gradio-container { background: radial-gradient(circle at top left, #0d1b2a 0%, #060d16 60%, #030609 100%) !important; font-family: 'Space Grotesk', sans-serif !important; } #title-block { text-align: center; padding: 28px 0 6px 0; } #title-block h1 { font-size: 42px; font-weight: 800; background: linear-gradient(90deg, #39ff6a, #a8ffb0, #e8e4d8); -webkit-background-clip: text; -webkit-text-fill-color: transparent; letter-spacing: -1px; margin: 0; } #title-block p { color: #7d8fa6; font-family: 'JetBrains Mono', monospace; font-size: 13px; letter-spacing: 1px; margin-top: 6px; } .gr-block.gr-box, .block { background: #0a1622 !important; border: 1px solid #16293d !important; border-radius: 16px !important; } footer { display: none !important; } .upload-box, .image-container { border-radius: 16px !important; } button.primary { background: linear-gradient(90deg, #1a4d2e, #39ff6a) !important; color: #030609 !important; font-weight: 700 !important; border: none !important; border-radius: 10px !important; letter-spacing: 0.5px; } button.primary:hover { box-shadow: 0 0 20px #39ff6a55 !important; } """ THEME = gr.themes.Base( primary_hue="green", neutral_hue="slate", font=[gr.themes.GoogleFont("Space Grotesk"), "sans-serif"], ).set( body_background_fill="#060d16", background_fill_primary="#0a1622", background_fill_secondary="#0d1b2a", border_color_primary="#16293d", body_text_color="#e8e4d8", block_title_text_color="#39ff6a", ) with gr.Blocks(title="SuS Meter") as demo: with gr.Column(elem_id="title-block"): gr.HTML("

🕵️ SuS Meter

AI IMAGE AUTHENTICITY DETECTOR — METADATA · PIXEL FORENSICS · WATERMARK PROVENANCE

") with gr.Row(): with gr.Column(scale=1): image_input = gr.Image(type="filepath", label="Upload an image", height=320) analyze_btn = gr.Button("🔍 Run SuS Analysis", variant="primary", size="lg") gr.Markdown( "Checks EXIF metadata, frequency-domain pixel anomalies, " "C2PA Content Credentials, and an independent watermark-likelihood heuristic. " "Not a substitute for Google's or OpenAI's official verification tools." ) with gr.Column(scale=1): gauge_output = gr.HTML() detail_output = gr.HTML() analyze_btn.click(fn=analyze_image, inputs=image_input, outputs=[gauge_output, detail_output]) if __name__ == "__main__": try: demo.launch(theme=THEME, css=CUSTOM_CSS) except TypeError: # Older Gradio versions take theme/css on Blocks() instead of launch() demo.launch()