""" 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"""
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()