Spaces:
Runtime error
Runtime error
| """ | |
| 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""" | |
| <div style="text-align:center; padding: 10px 0;"> | |
| <div style="font-size:15px; letter-spacing:2px; color:#e8e4d8; opacity:0.7; text-transform:uppercase; margin-bottom:6px;">SuS Meter</div> | |
| <div style="font-size:64px; font-weight:800; color:{color}; text-shadow:0 0 20px {color}66;">{total_score}%</div> | |
| <div style="width:100%; height:14px; background:#0d1b2a; border-radius:8px; overflow:hidden; margin:14px 0; border:1px solid #1b2f45;"> | |
| <div style="width:{total_score}%; height:100%; background:linear-gradient(90deg, #1a4d2e, #39ff6a); box-shadow:0 0 12px #39ff6a88;"></div> | |
| </div> | |
| <div style="font-size:20px; font-weight:700; color:{color}; margin-top:8px;">{verdict}</div> | |
| </div> | |
| """ | |
| details_html = "" | |
| for layer_name, reasons in all_reasons: | |
| details_html += f'<div style="margin-top:18px;"><div style="color:#39ff6a; font-weight:700; font-size:14px; letter-spacing:1px; text-transform:uppercase; margin-bottom:8px; border-bottom:1px solid #1b2f45; padding-bottom:6px;">{layer_name}</div>' | |
| for icon, text in reasons: | |
| details_html += f'<div style="display:flex; gap:10px; padding:6px 0; color:#e8e4d8; font-size:14px; line-height:1.4;"><span>{icon}</span><span>{text}</span></div>' | |
| details_html += "</div>" | |
| 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("<h1>π΅οΈ SuS Meter</h1><p>AI IMAGE AUTHENTICITY DETECTOR β METADATA Β· PIXEL FORENSICS Β· WATERMARK PROVENANCE</p>") | |
| 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( | |
| "<span style='color:#7d8fa6; font-size:12px;'>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.</span>" | |
| ) | |
| 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() | |