| import io |
| import numpy as np |
| import cv2 |
| from PIL import Image |
| from scipy.stats import kurtosis as sp_kurtosis |
|
|
| MODEL_CACHE = {} |
|
|
|
|
| def load_models(): |
| from transformers import pipeline |
| import torch |
|
|
| device_id = 0 if torch.cuda.is_available() else -1 |
| print(f"[ImgAuth] Using device: {'GPU (cuda:0)' if device_id == 0 else 'CPU'}") |
|
|
| model_ids = [ |
| ("umm_maybe", "umm-maybe/AI-image-detector"), |
| ("dima806", "dima806/ai_vs_real_image_detection"), |
| ("organika", "Organika/sdxl-detector"), |
| ] |
| for key, model_id in model_ids: |
| if key not in MODEL_CACHE: |
| try: |
| print(f"[ImgAuth] Loading model: {model_id}") |
| MODEL_CACHE[key] = pipeline( |
| "image-classification", model=model_id, device=device_id, framework="pt" |
| ) |
| except Exception as e: |
| print(f"[ImgAuth] Failed to load {model_id}: {e}") |
| MODEL_CACHE[key] = None |
| return MODEL_CACHE |
|
|
|
|
| def run_model(pipe, img): |
| try: |
| preds = pipe(img) |
| ai_s, real_s = 0.0, 0.0 |
| for p in preds: |
| label = p["label"].lower() |
| if any(k in label for k in ["ai", "fake", "artificial", "generated", "synthetic"]): |
| ai_s = max(ai_s, p["score"]) |
| elif any(k in label for k in ["real", "human", "natural", "authentic"]): |
| real_s = max(real_s, p["score"]) |
| if ai_s == 0 and real_s == 0 and len(preds) >= 2: |
| ai_s = preds[0]["score"] |
| real_s = preds[1]["score"] |
| elif ai_s == 0 and real_s == 0: |
| ai_s, real_s = 0.5, 0.5 |
| total = ai_s + real_s or 1 |
| return { |
| "ai_prob": round(ai_s / total, 4), |
| "real_prob": round(real_s / total, 4), |
| "raw": preds, |
| } |
| except Exception as e: |
| return {"ai_prob": 0.5, "real_prob": 0.5, "raw": [], "error": str(e)} |
|
|
|
|
| def run_model_multiscale(pipe, img_full): |
| img_512 = img_full.copy() |
| img_512.thumbnail((512, 512)) |
| result_512 = run_model(pipe, img_512) |
|
|
| w, h = img_full.size |
| import torch |
| if w > 600 and h > 600 and torch.cuda.is_available(): |
| crop_size = min(w, h, 384) |
| cx, cy = w // 2, h // 2 |
| half = crop_size // 2 |
| center_crop = img_full.crop((cx - half, cy - half, cx + half, cy + half)) |
| result_crop = run_model(pipe, center_crop) |
| ai_prob = result_512["ai_prob"] * 0.65 + result_crop["ai_prob"] * 0.35 |
| real_prob = result_512["real_prob"] * 0.65 + result_crop["real_prob"] * 0.35 |
| return { |
| "ai_prob": round(ai_prob, 4), |
| "real_prob": round(real_prob, 4), |
| "raw": result_512["raw"], |
| } |
| return result_512 |
|
|
|
|
| def noise_kurtosis_analysis(img): |
| try: |
| gray = np.array(img.convert("L"), dtype=np.float64) |
| residual = cv2.Laplacian(gray, cv2.CV_64F) |
| flat = residual.flatten() |
| flat = flat[np.abs(flat) > 0.5] |
| if len(flat) < 100: |
| return {"ai_prob": 0.5, "real_prob": 0.5, "kurtosis": 0.0, |
| "detail": "Insufficient noise data"} |
|
|
| k = float(sp_kurtosis(flat, fisher=True)) |
|
|
| if k > 5.0: |
| ai_prob = 0.12 |
| elif k > 2.5: |
| ai_prob = 0.28 |
| elif k > 1.0: |
| ai_prob = 0.42 |
| elif k > 0.0: |
| ai_prob = 0.55 |
| elif k > -0.5: |
| ai_prob = 0.65 |
| else: |
| ai_prob = 0.78 |
|
|
| label = "leptokurtic (real-like)" if k > 1.5 else "platykurtic (AI-like)" if k < 0 else "borderline" |
| return { |
| "ai_prob": round(ai_prob, 4), |
| "real_prob": round(1 - ai_prob, 4), |
| "kurtosis": round(k, 3), |
| "detail": f"Excess kurtosis={k:.3f} -> {label}", |
| } |
| except Exception as e: |
| return {"ai_prob": 0.5, "real_prob": 0.5, "kurtosis": 0.0, |
| "detail": f"Error: {str(e)[:60]}"} |
|
|
|
|
| import torch |
|
|
|
|
| def extract_vit_features_and_attentions(img): |
| try: |
| pipe = MODEL_CACHE.get("umm_maybe") |
| if not pipe or not hasattr(pipe, "model") or not hasattr(pipe, "image_processor"): |
| return None |
|
|
| model = pipe.model |
| processor = pipe.image_processor |
|
|
| inputs = processor(images=img, return_tensors="pt") |
| |
| model_device = next(model.parameters()).device |
| inputs = {k: v.to(model_device) for k, v in inputs.items()} |
|
|
| with torch.no_grad(): |
| outputs = model(**inputs, output_attentions=True, output_hidden_states=True) |
|
|
| return { |
| "logits": outputs.logits, |
| "attentions": outputs.attentions, |
| "hidden_states": outputs.hidden_states |
| } |
| except Exception as e: |
| print(f"[ImgAuth] Feature/Attention extraction error: {e}") |
| return None |
|
|
|
|
| def deep_feature_inconsistency_analysis(vit_data): |
| try: |
| if not vit_data or "hidden_states" not in vit_data: |
| return {"ai_prob": 0.5, "real_prob": 0.5, "variance": 0.0, "detail": "ViT data unavailable", "cos_dist": None, "grid_w": 0, "grid_h": 0, "patch_features": None} |
|
|
| last_hidden = vit_data["hidden_states"][-1][0].cpu() |
| N = last_hidden.shape[0] |
|
|
| import math |
| root_N = int(round(math.sqrt(N))) |
| if root_N * root_N == N: |
| patch_features = last_hidden |
| grid_w = grid_h = root_N |
| else: |
| root_N_minus_1 = int(round(math.sqrt(N - 1))) |
| if root_N_minus_1 * root_N_minus_1 == N - 1: |
| patch_features = last_hidden[1:, :] |
| grid_w = grid_h = root_N_minus_1 |
| else: |
| patch_features = last_hidden[1:, :] |
| N_patches = N - 1 |
| grid_w = int(math.sqrt(N_patches)) |
| grid_h = N_patches // grid_w |
| patch_features = patch_features[:grid_w * grid_h, :] |
|
|
| mean_feat = torch.mean(patch_features, dim=0, keepdim=True) |
| cos_sim = torch.nn.functional.cosine_similarity(patch_features, mean_feat, dim=1) |
| cos_dist = 1.0 - cos_sim |
|
|
| dist_variance = float(torch.var(cos_dist).item()) |
|
|
| if dist_variance > 0.0035: |
| ai_prob = 0.76 |
| detail = f"High deep feature inconsistency (variance={dist_variance:.6f})" |
| elif dist_variance > 0.0018: |
| ai_prob = 0.62 |
| detail = f"Moderate deep feature inconsistency (variance={dist_variance:.6f})" |
| elif dist_variance > 0.0006: |
| ai_prob = 0.44 |
| detail = f"Normal feature consistency (variance={dist_variance:.6f})" |
| else: |
| ai_prob = 0.28 |
| detail = f"High feature uniformity (variance={dist_variance:.6f})" |
|
|
| return { |
| "ai_prob": round(ai_prob, 4), |
| "real_prob": round(1.0 - ai_prob, 4), |
| "variance": round(dist_variance, 6), |
| "cos_dist": cos_dist, |
| "grid_w": grid_w, |
| "grid_h": grid_h, |
| "patch_features": patch_features, |
| "detail": detail |
| } |
| except Exception as e: |
| return {"ai_prob": 0.5, "real_prob": 0.5, "variance": 0.0, "detail": f"Error: {str(e)[:60]}", "cos_dist": None, "grid_w": 0, "grid_h": 0, "patch_features": None} |
|
|
|
|
| def _encode_overlay_to_base64(img_bgr): |
| """Encode a BGR numpy array to a Base64 JPEG data URL string.""" |
| success, buffer = cv2.imencode(".jpg", img_bgr, [cv2.IMWRITE_JPEG_QUALITY, 85]) |
| if not success: |
| return None |
| import base64 |
| b64 = base64.b64encode(buffer).decode("utf-8") |
| return f"data:image/jpeg;base64,{b64}" |
|
|
|
|
| def generate_heatmap_overlay(img, vit_data, cos_dist_tensor, grid_w, grid_h, patch_features): |
| """Generate attention and DFI heatmap overlays entirely in memory. |
| Returns Base64-encoded JPEG data URL strings (no files written to disk). |
| """ |
| try: |
| w, h = img.size |
| img_np = np.array(img.convert("RGB")) |
| img_bgr = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR) |
|
|
| att_b64 = None |
| dfi_b64 = None |
|
|
| |
| attentions = vit_data.get("attentions") if vit_data else None |
| att_grid = None |
| if attentions: |
| try: |
| last_layer_att = attentions[-1][0].cpu() |
| if last_layer_att.ndim == 3: |
| avg_att = torch.mean(last_layer_att, dim=0) |
| if avg_att.shape[0] == patch_features.shape[0]: |
| cls_attention = avg_att.mean(dim=0) |
| else: |
| cls_attention = avg_att[0, 1:] |
| if cls_attention.numel() == grid_w * grid_h: |
| att_grid = cls_attention.reshape(grid_w, grid_h).numpy() |
| except Exception as e: |
| print(f"[ImgAuth] Attention extraction error: {e}") |
|
|
| |
| if att_grid is None and patch_features is not None and grid_w > 0 and grid_h > 0: |
| try: |
| norm_feat = torch.norm(patch_features, dim=1) |
| att_grid = norm_feat.reshape(grid_w, grid_h).numpy() |
| except Exception as e: |
| print(f"[ImgAuth] Feature norm fallback error: {e}") |
|
|
| if att_grid is not None: |
| g_min, g_max = att_grid.min(), att_grid.max() |
| att_grid = (att_grid - g_min) / (g_max - g_min + 1e-8) |
| att_resized = cv2.resize((att_grid * 255).astype(np.uint8), (w, h), interpolation=cv2.INTER_CUBIC) |
| heatmap_att = cv2.applyColorMap(att_resized, cv2.COLORMAP_JET) |
| overlay_att = cv2.addWeighted(img_bgr, 0.6, heatmap_att, 0.4, 0) |
| att_b64 = _encode_overlay_to_base64(overlay_att) |
|
|
| |
| if cos_dist_tensor is not None and grid_w > 0 and grid_h > 0: |
| dist_grid = cos_dist_tensor.reshape(grid_w, grid_h).numpy() |
| g_min, g_max = dist_grid.min(), dist_grid.max() |
| dist_grid = (dist_grid - g_min) / (g_max - g_min + 1e-8) |
| dist_resized = cv2.resize((dist_grid * 255).astype(np.uint8), (w, h), interpolation=cv2.INTER_CUBIC) |
| heatmap_dfi = cv2.applyColorMap(dist_resized, cv2.COLORMAP_JET) |
| overlay_dfi = cv2.addWeighted(img_bgr, 0.6, heatmap_dfi, 0.4, 0) |
| dfi_b64 = _encode_overlay_to_base64(overlay_dfi) |
|
|
| return att_b64, dfi_b64 |
| except Exception as e: |
| print(f"[ImgAuth] Error generating heatmaps: {e}") |
| return None, None |
|
|
|
|
| def fft_spectral_analysis(img): |
| try: |
| gray = np.array(img.convert("L"), dtype=np.float64) |
| size = min(gray.shape[0], gray.shape[1], 512) |
| gray = cv2.resize(gray, (size, size)) |
|
|
| f_transform = np.fft.fft2(gray) |
| f_shift = np.fft.fftshift(f_transform) |
| magnitude = np.log1p(np.abs(f_shift)) |
|
|
| center = size // 2 |
| mask = np.ones_like(magnitude, dtype=bool) |
| mask[center - 5:center + 5, center - 5:center + 5] = False |
| outer_mag = magnitude[mask] |
|
|
| mean_mag = np.mean(outer_mag) |
| std_mag = np.std(outer_mag) |
| spike_threshold = mean_mag + 5.0 * std_mag |
| spike_count = np.sum(magnitude[mask] > spike_threshold) |
| spike_ratio = spike_count / len(outer_mag) if len(outer_mag) > 0 else 0 |
| sr = float(spike_ratio) |
|
|
| if sr > 0.01: |
| ai_prob = 0.72 |
| detail = f"Periodic artifacts detected (spike ratio={sr:.4f})" |
| elif sr > 0.005: |
| ai_prob = 0.58 |
| detail = f"Minor spectral anomalies ({sr:.4f})" |
| elif sr > 0.002: |
| ai_prob = 0.48 |
| detail = f"Faint spectral patterns ({sr:.4f})" |
| else: |
| ai_prob = 0.38 |
| detail = f"Clean spectrum (no periodic artifacts)" |
|
|
| return { |
| "ai_prob": round(ai_prob, 4), |
| "real_prob": round(1 - ai_prob, 4), |
| "spike_ratio": round(sr, 5), |
| "detail": detail, |
| } |
| except Exception as e: |
| return {"ai_prob": 0.5, "real_prob": 0.5, "spike_ratio": 0.0, |
| "detail": f"Error: {str(e)[:60]}"} |
|
|
|
|
| def color_histogram_analysis(img): |
| try: |
| rgb = np.array(img.convert("RGB")) |
| roughness_scores = [] |
| for channel in range(3): |
| hist, _ = np.histogram(rgb[:, :, channel], bins=64, range=(0, 256)) |
| hist = hist.astype(np.float64) |
| hist /= (hist.sum() + 1e-10) |
| diffs = np.diff(hist) |
| roughness_scores.append(float(np.std(diffs))) |
|
|
| avg_roughness = np.mean(roughness_scores) |
|
|
| if avg_roughness > 0.010: |
| ai_prob = 0.25 |
| detail = f"Natural histogram roughness ({avg_roughness:.5f})" |
| elif avg_roughness > 0.006: |
| ai_prob = 0.38 |
| detail = f"Moderate histogram roughness ({avg_roughness:.5f})" |
| elif avg_roughness > 0.003: |
| ai_prob = 0.52 |
| detail = f"Smooth histogram ({avg_roughness:.5f}) — possibly synthetic" |
| else: |
| ai_prob = 0.68 |
| detail = f"Very smooth histogram ({avg_roughness:.5f}) — likely AI" |
|
|
| return { |
| "ai_prob": round(ai_prob, 4), |
| "real_prob": round(1 - ai_prob, 4), |
| "roughness": round(avg_roughness, 6), |
| "detail": detail, |
| } |
| except Exception as e: |
| return {"ai_prob": 0.5, "real_prob": 0.5, "roughness": 0.0, |
| "detail": f"Error: {str(e)[:60]}"} |
|
|
|
|
| def jpeg_ghost_analysis(img): |
| try: |
| if img.format not in ("JPEG", "JPG", None): |
| return {"ai_prob": 0.5, "real_prob": 0.5, "detail": "Not a JPEG"} |
|
|
| rgb = np.array(img.convert("RGB"), dtype=np.float64) |
| ghost_scores = [] |
| for q in [60, 70, 80]: |
| buf = io.BytesIO() |
| img.save(buf, "JPEG", quality=q) |
| buf.seek(0) |
| recomp = np.array(Image.open(buf).convert("RGB"), dtype=np.float64) |
| diff = np.abs(rgb - recomp) |
| ghost_scores.append(float(np.mean(diff))) |
|
|
| min_ghost = min(ghost_scores) |
| max_ghost = max(ghost_scores) |
| spread = max_ghost - min_ghost |
|
|
| if spread < 1.0: |
| ai_prob = 0.58 |
| detail = f"Low ghost spread ({spread:.2f}) — uniform compression (possibly synthetic)" |
| elif spread < 3.0: |
| ai_prob = 0.45 |
| detail = f"Normal ghost spread ({spread:.2f})" |
| else: |
| ai_prob = 0.38 |
| detail = f"High ghost spread ({spread:.2f}) — natural re-compression history" |
|
|
| return { |
| "ai_prob": round(ai_prob, 4), |
| "real_prob": round(1 - ai_prob, 4), |
| "ghost_spread": round(spread, 3), |
| "detail": detail, |
| } |
| except Exception as e: |
| return {"ai_prob": 0.5, "real_prob": 0.5, "ghost_spread": 0.0, |
| "detail": f"Error: {str(e)[:60]}"} |
|
|
|
|
| def analyze_image_models(image_bytes: bytes) -> dict: |
| img_full = Image.open(io.BytesIO(image_bytes)).convert("RGB") |
|
|
| models = load_models() |
| votes = [] |
| signals = [] |
|
|
| |
| vit_data = extract_vit_features_and_attentions(img_full) |
|
|
| model_info = [ |
| ("umm_maybe", "umm-maybe ViT", 0.25), |
| ("dima806", "dima806 CNN", 0.25), |
| ("organika", "Organika SDXL-Detector", 0.25), |
| ] |
| active_dl_weight = 0.0 |
| for key, name, w in model_info: |
| if models.get(key): |
| r = run_model_multiscale(models[key], img_full) |
| votes.append({ |
| "detector": name, "type": "deep_learning", |
| "ai_prob": r["ai_prob"], "real_prob": r["real_prob"], "weight": w, |
| }) |
| active_dl_weight += w |
| signals.append(f"{name}: {int(r['ai_prob'] * 100)}% AI probability") |
| else: |
| signals.append(f"{name}: unavailable") |
|
|
| if active_dl_weight == 0: |
| pass |
| else: |
| for v in votes: |
| if v["type"] == "deep_learning": |
| v["weight"] = v["weight"] * (0.75 / active_dl_weight) |
|
|
| |
| nk = noise_kurtosis_analysis(img_full) |
| votes.append({ |
| "detector": "Noise Kurtosis Analysis", "type": "forensic", |
| "ai_prob": nk["ai_prob"], "real_prob": nk["real_prob"], "weight": 0.08, |
| "detail": nk.get("detail", ""), "kurtosis": nk.get("kurtosis", 0), |
| }) |
| signals.append(f"Noise Kurtosis: {nk['detail']}") |
|
|
| |
| dfi = deep_feature_inconsistency_analysis(vit_data) |
| votes.append({ |
| "detector": "Deep Feature Inconsistency (DFI)", "type": "forensic", |
| "ai_prob": dfi["ai_prob"], "real_prob": dfi["real_prob"], "weight": 0.07, |
| "detail": dfi.get("detail", ""), "variance": dfi.get("variance", 0), |
| }) |
| signals.append(f"DFI: {dfi['detail']}") |
|
|
| |
| fft = fft_spectral_analysis(img_full) |
| votes.append({ |
| "detector": "FFT Spectral Analysis", "type": "forensic", |
| "ai_prob": fft["ai_prob"], "real_prob": fft["real_prob"], "weight": 0.05, |
| "detail": fft.get("detail", ""), "spike_ratio": fft.get("spike_ratio", 0), |
| }) |
| signals.append(f"FFT: {fft['detail']}") |
|
|
| |
| ch = color_histogram_analysis(img_full) |
| votes.append({ |
| "detector": "Color Histogram Analysis", "type": "forensic", |
| "ai_prob": ch["ai_prob"], "real_prob": ch["real_prob"], "weight": 0.03, |
| "detail": ch.get("detail", ""), "roughness": ch.get("roughness", 0), |
| }) |
| signals.append(f"Color Histogram: {ch['detail']}") |
|
|
| |
| jg = jpeg_ghost_analysis(img_full) |
| votes.append({ |
| "detector": "JPEG Ghost Analysis", "type": "forensic", |
| "ai_prob": jg["ai_prob"], "real_prob": jg["real_prob"], "weight": 0.02, |
| "detail": jg.get("detail", ""), "ghost_spread": jg.get("ghost_spread", 0), |
| }) |
| signals.append(f"JPEG Ghost: {jg['detail']}") |
|
|
| |
| att_path, dfi_path = generate_heatmap_overlay( |
| img_full, |
| vit_data, |
| dfi.get("cos_dist"), |
| dfi.get("grid_w", 0), |
| dfi.get("grid_h", 0), |
| dfi.get("patch_features"), |
| ) |
|
|
| |
| for k in ["cos_dist", "patch_features"]: |
| if k in dfi: |
| del dfi[k] |
|
|
| total_w = sum(v["weight"] for v in votes) or 1 |
| w_ai = sum(v["ai_prob"] * v["weight"] for v in votes) / total_w |
| w_real = sum(v["real_prob"] * v["weight"] for v in votes) / total_w |
| tot = w_ai + w_real or 1 |
| w_ai /= tot |
| w_real /= tot |
|
|
| dl_pct = int(min(active_dl_weight / total_w * 100, 100)) |
| forensic_pct = 100 - dl_pct |
|
|
| return { |
| "ai_points": int(w_ai * 100), |
| "real_points": int(w_real * 100), |
| "weighted_ai_prob": round(w_ai, 4), |
| "votes": votes, |
| "signals": signals, |
| "forensics": { |
| "kurtosis": nk, "dfi": dfi, "fft": fft, |
| "color_histogram": ch, "jpeg_ghost": jg, |
| }, |
| "attention_heatmap": att_path, |
| "dfi_heatmap": dfi_path, |
| "priority_note": f"DL-dominant ensemble: 3 models ({dl_pct}%) + 5 forensics ({forensic_pct}%).", |
| } |
|
|
|
|
|
|
|
|