mojiotoo's picture
Update app.py
4d1e70c verified
Raw
History Blame Contribute Delete
18.6 kB
"""
Pipeline:
image (BGR)
β”œβ”€β”€ 4-channel tensor (RGB normalized + FFT power spectrum) β†’ EfficientNet-B0 β†’ 1280
β”œβ”€β”€ FFT azimuthal avg at 256Γ—256 β†’ 128
└── noise (gray - GaussianBlur) FFT azimuthal avg at 256Γ—256 β†’ 128
────
1536 β†’ StandardScaler (inside Pipeline) β†’ SVM / RF
Model files : v5 (preferred):
best_model_v5.pth CNN weights (EfficientNet-B0 4-ch, v5 head: GELU + Dropout 0.4/0.3)
rbf_svm_v5.onnx sklearn Pipeline (StandardScaler + RBF SVC)
features_v5.npz training features for k-NN type attribution
Run:
pip install flask flask-cors onnxruntime torch timm opencv-python-headless scipy numpy pillow scikit-learn joblib
python app.py
"""
from flask import Flask, request, jsonify
from flask_cors import CORS
import numpy as np
import cv2
import io
import torch
import torch.nn as nn
import timm
from scipy import ndimage
from PIL import Image
import joblib
import os
app = Flask(__name__)
CORS(app)
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
FFT_SIZE = 256
AZ_BINS = FFT_SIZE // 2 # 128
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# v5 paths
CNN_V5_PATH = "best_model_v5.pth"
SKL_PKL_PATHS = ["linear_svm_v5.pkl", "rbf_svm_v5.pkl", "rf_model_v5.pkl"]
FEATURES_NPZ = "features_v5.npz"
# Used only in ONNX fallback mode
DECISION_THRESHOLD = 0.35
CALIBRATION_TEMPERATURE = 3.0
IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
# ---------------------------------------------------------------------------
# Manipulation type human-readable labels & descriptions
# ---------------------------------------------------------------------------
MANIPULATION_LABELS = {
"faceswap_autoencoder": "Face Swap (Autoencoder)",
"stylegan2": "StyleGAN2 Synthesis",
"stylegan_ffhq": "StyleGAN (FFHQ-trained)",
"stylegan_celeba": "StyleGAN (CelebA-trained)",
"stylegan_variant": "StyleGAN Variant",
"gan_mixed": "GAN Synthesis (Mixed)",
"faceswap_dfd": "Face Swap (DeepFaceLab-style)",
"stargan": "StarGAN Attribute Editing",
"pggan_v1": "Progressive GAN v1",
"pggan_v2": "Progressive GAN v2",
"faceapp": "FaceApp AI Manipulation",
}
MANIPULATION_DESCRIPTIONS = {
"faceswap_autoencoder": "This replaces a person's face using an AI autoencoder. It often leaves behind subtle blending lines and mismatched lighting.",
"stylegan2": "This generates entirely fake, photorealistic faces. It usually leaves hidden grid-like artifacts in the image pixels.",
"stylegan_ffhq": "This creates highly realistic fake faces, leaving behind subtle AI frequency traces.",
"stylegan_celeba": "This generates fake faces based on celebrity photos, showing specific AI noise patterns.",
"stylegan_variant": "This is a variant of the StyleGAN family, which typically leaves repeated upsampling artifacts hidden in the image.",
"gan_mixed": "This looks like a mix of different AI generators, showing unusual and diverse artificial noise patterns.",
"faceswap_dfd": "This is a DeepFaceLab-style face swap. It usually leaves tiny seam artifacts around the edges of the face.",
"stargan": "This edits specific facial features like hair, gender, or age, often leaving behind a slight checkerboard effect.",
"pggan_v1": "This progressively generates fake faces from low to high resolution, which often leaves blurry artifacts in the background.",
"pggan_v2": "An improved AI generator, but it still leaves faint artificial traces around hair and backgrounds.",
"faceapp": "This applies heavy AI filters to a real photo. It drastically smooths out the skin and flattens natural textures.",
}
# CNN : v5 architecture
class DeepfakeDetector(nn.Module):
def __init__(self):
super().__init__()
self.backbone = timm.create_model("efficientnet_b0", pretrained=False, num_classes=0)
old_conv = self.backbone.conv_stem
new_conv = nn.Conv2d(
4, old_conv.out_channels,
kernel_size=old_conv.kernel_size,
stride=old_conv.stride,
padding=old_conv.padding,
bias=old_conv.bias is not None,
)
self.backbone.conv_stem = new_conv
d = self.backbone.num_features # 1280
self.head = nn.Sequential(
nn.Dropout(0.4), nn.Linear(d, 256), nn.GELU(),
nn.Dropout(0.3), nn.Linear(256, 1),
)
def forward(self, x):
return self.head(self.backbone(x))
def get_features(self, x):
"""Return 1280-d backbone embedding (no head). Used for SVM + k-NN."""
return self.backbone(x)
# ---------------------------------------------------------------------------
# Load CNN weights : prefer v5, fall back to v4
# ---------------------------------------------------------------------------
_cnn_path = CNN_V5_PATH
if not os.path.exists(_cnn_path):
raise FileNotFoundError(
f"CNN weights not found. Expected '{CNN_V5_PATH}'."
)
feature_model = DeepfakeDetector().to(DEVICE)
state = torch.load(_cnn_path, map_location=DEVICE, weights_only=False)
feature_model.load_state_dict(state, strict=False)
feature_model.eval()
print(f"βœ… CNN loaded from {_cnn_path}")
USE_SKL = False
skl_clf = None
svm_session = None
SVM_INPUT_NAME = None
feature_scaler = None
for pkl_path in SKL_PKL_PATHS:
if os.path.exists(pkl_path):
skl_clf = joblib.load(pkl_path)
USE_SKL = True
print(f"βœ… Sklearn classifier loaded from {pkl_path} (StandardScaler embedded in pipeline)")
break
if not USE_SKL:
import onnxruntime as rt
ONNX_PATH = "rbf_svm_v5.onnx" # ← change to your actual filename
if os.path.exists(ONNX_PATH):
svm_session = rt.InferenceSession(ONNX_PATH)
SVM_INPUT_NAME = svm_session.get_inputs()[0].name
print(f"βœ… ONNX classifier loaded from {ONNX_PATH}")
else:
print(f"❌ ONNX file not found at '{ONNX_PATH}'")
_knn = None
_fake_types_train = None
if os.path.exists(FEATURES_NPZ):
try:
from sklearn.neighbors import NearestNeighbors
_d = np.load(FEATURES_NPZ, allow_pickle=True)
X_tr = _d["X_train"]
y_tr = _d["y_train"].astype(int)
dt_tr = _d["dtype_train"]
_d.close()
_fake_mask = y_tr == 1
_knn = NearestNeighbors(n_neighbors=min(15, int(_fake_mask.sum())))
_knn.fit(X_tr[_fake_mask])
_fake_types_train = np.asarray(dt_tr)[_fake_mask]
print(f"βœ… k-NN type attributor ready")
except Exception as exc:
print(f"⚠️ k-NN setup failed ({exc})")
else:
print(f"ℹ️ {FEATURES_NPZ} not found")
def compute_azimuthal_average(spectrum_2d: np.ndarray) -> np.ndarray:
h, w = spectrum_2d.shape
cy, cx = h // 2, w // 2
Y, X = np.ogrid[:h, :w]
r = np.sqrt((X - cx) ** 2 + (Y - cy) ** 2).astype(int)
max_r = min(cy, cx)
return ndimage.mean(spectrum_2d, labels=r, index=np.arange(0, max_r))
def extract_combined_features(img_bgr: np.ndarray):
"""
Extract the 1536-d feature vector that was used to train the SVM.
Returns
-------
feat : np.ndarray shape (1536,)
spectral : dict 'az' and 'nz' lists for explanation / debug
"""
# ── 4-channel input tensor ─────────────────────────────────────────────
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
r224 = cv2.resize(img_rgb, (224, 224), interpolation=cv2.INTER_LINEAR)
norm = (r224.astype(np.float32) / 255.0 - IMAGENET_MEAN) / IMAGENET_STD
t3 = torch.from_numpy(norm.transpose(2, 0, 1))
gray224 = cv2.cvtColor(r224, cv2.COLOR_RGB2GRAY).astype(np.float32)
fs0 = np.fft.fftshift(np.fft.fft2(gray224))
ps = np.log1p(np.abs(fs0) ** 2)
ps = (ps - ps.min()) / (ps.max() - ps.min() + 1e-8)
fft_ch = torch.from_numpy(ps.astype(np.float32)).unsqueeze(0)
tensor_4ch = torch.cat([t3, fft_ch], dim=0).unsqueeze(0).to(DEVICE)
# ── CNN backbone β†’ 1280-d (matches notebook Cell 11 / Cell 17) ─────────
with torch.no_grad():
cnn_feat = feature_model.get_features(tensor_4ch).float().cpu().numpy() # (1, 1280)
# ── FFT azimuthal profile at FFT_SIZE ──────────────────────────────────
gray = cv2.resize(
cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY), (FFT_SIZE, FFT_SIZE)
).astype(np.float32)
fs = np.fft.fftshift(np.fft.fft2(gray))
power = np.log1p(np.abs(fs) ** 2)
az = compute_azimuthal_average(power).astype(np.float32) # 128-d
# ── Noise-residual FFT azimuthal profile ───────────────────────────────
blurred = cv2.GaussianBlur(gray, (5, 5), 1.0)
noise = gray - blurred
nfs = np.fft.fftshift(np.fft.fft2(noise))
noise_power = np.log1p(np.abs(nfs) ** 2)
nz = compute_azimuthal_average(noise_power).astype(np.float32) # 128-d
combined = np.concatenate([cnn_feat[0], az, nz]).astype(np.float32)
assert combined.shape == (1536,), f"Bad feature dim: {combined.shape}"
spectral = {"az": az.tolist(), "nz": nz.tolist()}
return combined, spectral
def attribute_type(feat_1d: np.ndarray) -> list:
# Vote among 15 nearest fake-training-set neighbours.
if _knn is None:
return []
_, idx = _knn.kneighbors(feat_1d.reshape(1, -1))
neigh = _fake_types_train[idx[0]]
vals, counts = np.unique(neigh, return_counts=True)
order = np.argsort(-counts)
return [(str(vals[o]), float(counts[o] / len(neigh) * 100)) for o in order]
def analyze_spectral_profile(az: list, nz: list) -> tuple[list, str]:
# Inspect the azimuthal power and noise-residual profiles.
az_arr = np.array(az, dtype=np.float32)
nz_arr = np.array(nz, dtype=np.float32)
n = len(az_arr)
if n < 8:
return [], "Image is too small for a clear frequency analysis."
low_end = max(1, n // 8)
mid_s = n // 4
mid_e = n // 2
hi_s = 3 * n // 4
low_mean = float(az_arr[:low_end].mean())
hi_mean = float(az_arr[hi_s:].mean())
nz_low = float(nz_arr[:low_end].mean())
nz_hi = float(nz_arr[hi_s:].mean())
flags = []
# ── Flag 1: elevated high-frequency power (GAN upsampling) ────────────
decay_ratio = hi_mean / (low_mean + 1e-6)
if decay_ratio > 0.55:
flags.append("high_freq_elevation")
# ── Flag 2: noise-residual elevation in high bands (blend / re-encode) ─
noise_ratio = nz_hi / (nz_low + 1e-6)
if noise_ratio > 0.75:
flags.append("noise_floor_elevated")
# ── Flag 3: non-monotonic spectral profile (GAN periodic spikes) ───────
diffs = np.diff(az_arr[mid_s:])
sign_changes = int(((diffs[:-1] * diffs[1:]) < 0).sum())
if sign_changes > n // 6:
flags.append("spectral_oscillation")
# ── Flag 4: flat mid-band (over-smoothing : FaceApp / inpainting) ──────
mid_std = float(az_arr[mid_s:mid_e].std())
if mid_std < 0.04 * (low_mean + 1e-6):
flags.append("mid_band_flat")
return flags
def build_explanation(is_fake: bool, p_fake: float, manip_types: list, spectral_flags: list) -> str:
pct_fake = round(p_fake * 100, 1)
pct_real = round((1.0 - p_fake) * 100, 1)
if not is_fake:
if not spectral_flags:
return f"This image looks authentic. We are {pct_real}% confident it is real. The underlying frequency patterns are completely natural with no obvious AI traces."
else:
return f"This image looks authentic. We are {pct_real}% confident it is real. We picked up a few minor frequency quirks, but our core model confirms the overall image structure is natural and not manipulated."
parts = [f"We detected that this image is AI-generated or manipulated (estimated {pct_fake}% probability)."]
if manip_types:
top_type, top_pct = manip_types[0]
label = MANIPULATION_LABELS.get(top_type, top_type)
desc = MANIPULATION_DESCRIPTIONS.get(top_type, "")
parts.append(f"The patterns closely match {label}. {desc}")
elif spectral_flags:
if "high_freq_elevation" in spectral_flags or "spectral_oscillation" in spectral_flags:
parts.append("The image contains distinct hidden pixel patterns that are a dead giveaway for GAN-style AI generators.")
elif "noise_floor_elevated" in spectral_flags:
parts.append("The background noise levels are uneven, which is usually a strong sign of a face-swap.")
elif "mid_band_flat" in spectral_flags:
parts.append("The textures are unnaturally smooth, which usually points to a pyt AI filter like FaceApp.")
else:
parts.append("While the frequency traces are subtle, our deep learning model strongly recognized features from known deepfake datasets.")
return " ".join(parts)
def get_confidence_level(p_fake: float) -> str:
threshold = 0.5 if USE_SKL else DECISION_THRESHOLD
distance = abs(p_fake - threshold)
if distance < 0.10:
return "uncertain"
elif distance < 0.25:
return "low"
elif distance < 0.40:
return "medium"
else:
return "high"
# ---------------------------------------------------------------------------
# /analyze endpoint
# ---------------------------------------------------------------------------
@app.route("/")
def index():
return "OK", 200
@app.route("/analyze", methods=["POST"])
def analyze():
if "image" not in request.files:
return jsonify({"error": "No image provided."}), 400
try:
raw = request.files["image"].read()
pil_img = Image.open(io.BytesIO(raw)).convert("RGB")
img_rgb = np.array(pil_img)
img_bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)
# ── Feature extraction ─────────────────────────────────────────────
features, spectral = extract_combined_features(img_bgr)
feat_2d = features.reshape(1, -1)
# ── Classification ─────────────────────────────────────────────────
if USE_SKL:
# sklearn Pipeline handles scaling internally
p_fake = float(skl_clf.predict_proba(feat_2d)[0, 1])
p_real = 1.0 - p_fake
is_fake = p_fake >= 0.5
else:
# ONNX
scaled = (
feature_scaler.transform(feat_2d).astype(np.float32)
if feature_scaler is not None
else feat_2d.astype(np.float32)
)
label_arr, prob_list = svm_session.run(None, {SVM_INPUT_NAME: scaled})
p_fake_raw = float(prob_list[0][1])
# Temperature scaling (smooths isotonic step-function probabilities)
eps = 1e-6
p_clip = float(np.clip(p_fake_raw, eps, 1.0 - eps))
logit_p = np.log(p_clip / (1.0 - p_clip))
p_fake = float(1.0 / (1.0 + np.exp(-logit_p / CALIBRATION_TEMPERATURE)))
p_real = 1.0 - p_fake
is_fake = bool(int(label_arr[0]) == 1)
confidence = get_confidence_level(p_fake)
manip_types = attribute_type(features) if is_fake else []
spectral_flags = analyze_spectral_profile(spectral["az"], spectral["nz"])
spectral_summary = ""
explanation = build_explanation(
is_fake, p_fake, manip_types, spectral_flags
)
manip_label = None
manip_scores = []
if is_fake:
if manip_types:
top_type, _ = manip_types[0]
manip_label = MANIPULATION_LABELS.get(top_type, top_type)
manip_scores = [
{
"type": t,
"label": MANIPULATION_LABELS.get(t, t),
"confidence": round(p, 1),
}
for t, p in manip_types[:5]
]
elif spectral_flags:
if "high_freq_elevation" in spectral_flags or "spectral_oscillation" in spectral_flags:
manip_label = "GAN Synthesis"
elif "noise_floor_elevated" in spectral_flags:
manip_label = "Face Swap / Re-encoding"
elif "mid_band_flat" in spectral_flags:
manip_label = "AI Filter / Smoothing"
else:
manip_label = "AI Manipulation Type Unknown"
print(
f"DEBUG: p_fake={p_fake:.4f} label={'FAKE' if is_fake else 'REAL'} "
f"confidence={confidence} manip={manip_label}"
)
return jsonify({
"probability": round(p_fake * 100, 2),
"label": "AI Generated / Fake" if is_fake else "Authentic Media",
"is_fake": is_fake,
"confidence": confidence,
"p_real": round(p_real * 100, 2),
"p_fake": round(p_fake * 100, 2),
"explanation": explanation,
"manipulation_type": manip_label,
"manipulation_scores": manip_scores,
"spectral_flags": spectral_flags,
"spectral_summary": spectral_summary,
"threshold_used": 0.5 if USE_SKL else DECISION_THRESHOLD,
})
except Exception as exc:
import traceback
traceback.print_exc()
return jsonify({"error": f"Analysis failed: {exc}"}), 500
if __name__ == "__main__":
app.run(host="0.0.0.0", port=7860, debug=False)