AI-IMG-DETECTOR / app.py
PJ2005's picture
Upload app.py
e7e739c verified
Raw
History Blame Contribute Delete
13 kB
"""
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()