ooki0626 commited on
Commit
2ad8303
Β·
verified Β·
1 Parent(s): 1804086

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +123 -138
app.py CHANGED
@@ -1,27 +1,27 @@
1
  import io
2
- from PIL import Image, ImageChops, ImageStat, ExifTags
 
 
 
3
  import gradio as gr
4
 
5
- # Possible generator keywords that may appear in EXIF metadata (extendable)
6
- GENERATOR_KEYWORDS = [
7
- "stable diffusion", "stability.ai", "sdxl", "midjourney", "dall", "openai",
8
- "novelai", "leonardo", "kaiber", "flux", "comfyui", "automatic1111", "invokeai"
9
- ]
10
 
11
- def to_rgb_flat(img, bg=(255, 255, 255)):
12
- """Ensure RGB mode; when RGBA/transparent, composite on a white background to avoid JPEG save errors."""
13
- if img.mode == "RGB":
14
- return img
15
- if img.mode in ("RGBA", "LA", "P"):
16
- bg_img = Image.new("RGB", img.size, bg)
17
- if img.mode == "P":
18
- img = img.convert("RGBA")
19
- bg_img.paste(img, mask=img.split()[-1] if "A" in img.getbands() else None)
20
- return bg_img
21
- return img.convert("RGB")
22
-
23
- def resize_max(img, max_side=1024):
24
- """Limit the longest side to 1024 px to reduce memory and avoid encoding errors."""
25
  w, h = img.size
26
  m = max(w, h)
27
  if m <= max_side:
@@ -29,133 +29,118 @@ def resize_max(img, max_side=1024):
29
  scale = max_side / float(m)
30
  return img.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
31
 
32
- def compute_ela_score(img, quality=95):
33
- """
34
- ELA (Error Level Analysis): recompress the image at given JPEG quality, then compute the mean/std of differences.
35
- If fails (unsupported mode/codec), return (None, None) and let upper layer handle gracefully.
36
- """
37
- try:
38
- img_rgb = to_rgb_flat(img)
39
- img_rgb = resize_max(img_rgb, 1024)
40
- buf = io.BytesIO()
41
- img_rgb.save(buf, "JPEG", quality=quality, optimize=True)
42
- buf.seek(0)
43
- recompressed = Image.open(buf).convert("RGB")
44
- ela = ImageChops.difference(img_rgb, recompressed)
45
- stat = ImageStat.Stat(ela)
46
- mean = float(sum(stat.mean) / len(stat.mean))
47
- std = float(sum(stat.stddev) / len(stat.stddev))
48
- return mean, std
49
- except Exception:
50
- # Retry once with lower JPEG quality (more conservative)
51
- try:
52
- img_rgb = to_rgb_flat(img)
53
- img_rgb = resize_max(img_rgb, 1024)
54
- buf = io.BytesIO()
55
- img_rgb.save(buf, "JPEG", quality=85)
56
- buf.seek(0)
57
- recompressed = Image.open(buf).convert("RGB")
58
- ela = ImageChops.difference(img_rgb, recompressed)
59
- stat = ImageStat.Stat(ela)
60
- mean = float(sum(stat.mean) / len(stat.mean))
61
- std = float(sum(stat.stddev) / len(stat.stddev))
62
- return mean, std
63
- except Exception:
64
- return None, None # Fully give up on ELA and let upper layer degrade gracefully
65
-
66
- def extract_exif_flags(img):
67
- """Read common EXIF fields and search for generator keywords.
68
- Ignore exceptions and return as much info as possible."""
69
- exif = {}
70
- try:
71
- raw = img.getexif()
72
- for k, v in raw.items():
73
- tag = ExifTags.TAGS.get(k, str(k))
74
- exif[tag] = v
75
- except Exception:
76
- pass
77
-
78
- exif_str = " ".join([str(v).lower() for v in exif.values()]) if exif else ""
79
- has_camera_fields = any(tag in exif for tag in ["Make", "Model", "LensModel", "DateTimeOriginal"])
80
- has_generator_kw = any(kw in exif_str for kw in GENERATOR_KEYWORDS)
81
- empty_exif = (len(exif) == 0)
82
-
83
- preview = {}
84
- for k in ["Make", "Model", "LensModel", "Software", "DateTimeOriginal"]:
85
- if k in exif:
86
- preview[k] = str(exif[k])
87
-
88
  return {
89
- "has_camera_fields": has_camera_fields,
90
- "has_generator_kw": has_generator_kw,
91
- "empty_exif": empty_exif,
92
- "exif_preview": preview
93
  }
94
 
95
- def ai_likelihood(img):
96
- """
97
- Main entry point: catch any exceptions and return JSON-friendly output instead of crashing the frontend.
98
- """
99
- try:
100
- if img is None:
101
- return {"label": "Error", "message": "No image uploaded."}
102
-
103
- info = extract_exif_flags(img)
104
- ela_mean, ela_std = compute_ela_score(img)
105
-
106
- # Initial score (0.5 = uncertain)
107
- score = 0.5
108
- reasons = []
109
-
110
- if info["has_generator_kw"]:
111
- score += 0.4
112
- reasons.append("Metadata contains generator keywords (e.g., Stable Diffusion/Midjourney).")
113
- if info["has_camera_fields"]:
114
- score -= 0.2
115
- reasons.append("Camera EXIF fields found (Make/Model/Lens/DateTimeOriginal).")
116
- if info["empty_exif"]:
117
- score += 0.1
118
- reasons.append("No EXIF found (common in exported AI images or screenshots).")
119
-
120
- if ela_mean is not None and ela_std is not None:
121
- if ela_mean < 2.0 and ela_std < 2.0:
122
- score += 0.15
123
- reasons.append("ELA mean/std are very low β†’ uniform compression error (AI-like).")
124
- elif ela_mean > 4.0 or ela_std > 4.0:
125
- score -= 0.05
126
- reasons.append("ELA mean/std are higher β†’ natural camera/post-processing artifacts (Real-like).")
 
 
127
  else:
128
- reasons.append("ELA failed (unsupported format/codec); decision based on metadata only.")
 
 
129
 
130
- score = max(0.0, min(1.0, score))
131
- label = "Likely AI" if score >= 0.6 else ("Uncertain" if 0.4 <= score < 0.6 else "Likely Real")
 
 
132
 
133
- return {
134
- "label": label,
135
- "ai_probability": round(score, 3),
136
- "ela_mean": None if ela_mean is None else round(ela_mean, 3),
137
- "ela_std": None if ela_std is None else round(ela_std, 3),
138
- "exif": info["exif_preview"],
139
- "notes": reasons or ["No strong signals; result uncertain."]
140
- }
141
 
142
- except Exception as e:
143
- # Fallback: show error details in JSON instead of crashing frontend
144
- return {"label": "Error", "message": str(e)}
 
 
 
145
 
 
146
  with gr.Blocks() as demo:
147
- gr.Markdown("""
148
- # πŸ•΅οΈ FakeSpotter (Heuristic Demo)
149
- Upload an image to estimate whether it is **AI-generated** or **Real** using simple FREE heuristics:
150
- - Metadata scan (generator keywords vs. camera EXIF)
151
- - ELA (Error Level Analysis) statistics
152
- > ⚠️ Classroom demo, **not** a forensic tool.
153
- """)
154
- inp = gr.Image(type="pil", label="Upload image")
155
- out = gr.JSON(label="Result")
156
- btn = gr.Button("Analyze")
157
- btn.click(ai_likelihood, inputs=inp, outputs=out)
 
158
 
159
  if __name__ == "__main__":
160
  demo.launch()
161
-
 
1
  import io
2
+ from typing import List, Tuple, Dict, Any
3
+ from PIL import Image
4
+ import numpy as np
5
+ import torch
6
  import gradio as gr
7
 
8
+ # Face detector
9
+ from facenet_pytorch import MTCNN
 
 
 
10
 
11
+ # HF image classifier
12
+ from transformers import AutoImageProcessor, AutoModelForImageClassification
13
+
14
+ # ========= Config =========
15
+ # You can change the model below to another public model on Hugging Face
16
+ # Example: prithivMLmods/Deep-Fake-Detector-v2-Model (binary: Deepfake vs Realism)
17
+ MODEL_ID = "prithivMLmods/Deep-Fake-Detector-v2-Model"
18
+ DEVICE = "cpu" # Use "cuda" if GPU is available
19
+ MAX_SIDE = 640 # Resize to keep the longest side ≀ 640px for efficiency
20
+ # =========================
21
+
22
+ # ---- Utilities ----
23
+ def resize_keep_ratio(img: Image.Image, max_side: int = MAX_SIDE) -> Image.Image:
24
+ """Resize the image while keeping aspect ratio and limit max side length."""
25
  w, h = img.size
26
  m = max(w, h)
27
  if m <= max_side:
 
29
  scale = max_side / float(m)
30
  return img.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
31
 
32
+ def canonical_label(label: str) -> str:
33
+ """Map model-specific labels to canonical 'fake' or 'real' categories."""
34
+ l = label.lower()
35
+ if any(k in l for k in ["fake", "ai", "synthetic", "deepfake"]):
36
+ return "fake"
37
+ if any(k in l for k in ["real", "authentic", "genuine"]):
38
+ return "real"
39
+ # Default fallback if label doesn't match known keywords
40
+ return label
41
+
42
+ def rank_probs(id2label: Dict[int, str], probs: List[float]) -> List[Tuple[str, float]]:
43
+ """Return sorted list of (label, probability) pairs."""
44
+ pairs = [(id2label[i], float(probs[i])) for i in range(len(probs))]
45
+ return sorted(pairs, key=lambda x: x[1], reverse=True)
46
+
47
+ # ---- Load models (once) ----
48
+ mtcnn = MTCNN(keep_all=True, device=DEVICE)
49
+ processor = AutoImageProcessor.from_pretrained(MODEL_ID)
50
+ clf = AutoModelForImageClassification.from_pretrained(MODEL_ID).to(DEVICE)
51
+ id2label = clf.config.id2label
52
+
53
+ # ---- Core inference ----
54
+ @torch.no_grad()
55
+ def classify_pil(img: Image.Image) -> Dict[str, Any]:
56
+ """Run classification on a single PIL image and return ranked probabilities."""
57
+ inputs = processor(images=img, return_tensors="pt")
58
+ inputs = {k: v.to(DEVICE) for k, v in inputs.items()}
59
+ logits = clf(**inputs).logits
60
+ probs = torch.softmax(logits, dim=-1).squeeze().tolist()
61
+ ranked = rank_probs(id2label, probs)
62
+
63
+ # Extract approximate fake / real probabilities based on label keywords
64
+ fake_p, real_p = None, None
65
+ for lbl, p in ranked:
66
+ cat = canonical_label(lbl)
67
+ if cat == "fake" and fake_p is None:
68
+ fake_p = p
69
+ if cat == "real" and real_p is None:
70
+ real_p = p
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  return {
72
+ "top": ranked[:3],
73
+ "fake_prob": fake_p,
74
+ "real_prob": real_p
 
75
  }
76
 
77
+ def analyze(img: Image.Image) -> Dict[str, Any]:
78
+ """Main analysis pipeline: detect faces, classify each face or full image."""
79
+ if img is None:
80
+ return {"error": "No image uploaded."}
81
+
82
+ img = img.convert("RGB")
83
+ img = resize_keep_ratio(img, MAX_SIDE)
84
+
85
+ # 1) Face detection
86
+ boxes, _ = mtcnn.detect(img)
87
+ crops = []
88
+ if boxes is not None:
89
+ for (x1, y1, x2, y2) in boxes:
90
+ x1 = max(0, int(x1)); y1 = max(0, int(y1))
91
+ x2 = min(img.width, int(x2)); y2 = min(img.height, int(y2))
92
+ if x2 > x1 and y2 > y1:
93
+ crops.append(img.crop((x1, y1, x2, y2)))
94
+
95
+ results = []
96
+ if crops:
97
+ # 2) Classify each detected face
98
+ for idx, face in enumerate(crops, 1):
99
+ r = classify_pil(face)
100
+ results.append({"face": idx, **r})
101
+ else:
102
+ # 3) If no face is detected, classify the whole image
103
+ r = classify_pil(img)
104
+ results.append({"face": None, **r})
105
+
106
+ # Aggregate: use median of fake probabilities across all faces
107
+ fake_scores = []
108
+ for r in results:
109
+ if r.get("fake_prob") is not None:
110
+ fake_scores.append(r["fake_prob"])
111
  else:
112
+ # Fallback: use top-1 label keyword
113
+ top1 = r["top"][0][0]
114
+ fake_scores.append(1.0 if canonical_label(top1) == "fake" else 0.0)
115
 
116
+ if fake_scores:
117
+ overall_fake = float(np.median(fake_scores))
118
+ else:
119
+ overall_fake = 0.5
120
 
121
+ label = "Likely AI/Deepfake" if overall_fake >= 0.6 else ("Uncertain" if overall_fake >= 0.4 else "Likely Real")
 
 
 
 
 
 
 
122
 
123
+ return {
124
+ "label": label,
125
+ "overall_fake_probability": round(overall_fake, 3),
126
+ "faces_detected": len(crops),
127
+ "per_face_results": results
128
+ }
129
 
130
+ # ---- Gradio UI ----
131
  with gr.Blocks() as demo:
132
+ gr.Markdown(
133
+ """
134
+ # πŸ•΅οΈ FakeSpotter β€” Image Deepfake Detector (CPU)
135
+ Upload an image. If a face is detected, each face is analyzed; otherwise, the whole image is classified.
136
+ **No EXIF is used.** Model can be swapped by editing `MODEL_ID` in the code.
137
+ > Classroom demo β€” not a forensic tool.
138
+ """
139
+ )
140
+ with gr.Row():
141
+ inp = gr.Image(type="pil", label="Upload image")
142
+ out = gr.JSON(label="Results")
143
+ gr.Button("Analyze").click(analyze, inputs=inp, outputs=out)
144
 
145
  if __name__ == "__main__":
146
  demo.launch()