reihannudin commited on
Commit
457cfc5
Β·
1 Parent(s): 2cfb76f

Deploy deepfake detector backend

Browse files
Files changed (9) hide show
  1. Dockerfile +13 -0
  2. app.py +229 -0
  3. best_model.pth +3 -0
  4. check_weights.py +39 -0
  5. download_model.py +16 -0
  6. requirements.txt +9 -0
  7. scaler.pkl +3 -0
  8. svm_linear_model.onnx +3 -0
  9. testbatch.py +116 -0
Dockerfile ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY . .
6
+
7
+ RUN pip install --no-cache-dir flask flask-cors onnxruntime numpy Pillow scipy torch timm opencv-python-headless scikit-learn joblib
8
+
9
+ ENV PORT=7860
10
+
11
+ EXPOSE 7860
12
+
13
+ CMD ["python", "app.py"]
app.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Deepfake detector backend β€” with tuned threshold.
3
+
4
+ Pipeline:
5
+ image (BGR)
6
+ β”œβ”€β”€ 4-channel tensor (RGB normalized + FFT power spectrum) β†’ EfficientNet-B0 β†’ 1280
7
+ β”œβ”€β”€ FFT azimuthal avg at 256Γ—256 β†’ 128
8
+ └── noise (gray - GaussianBlur) FFT azimuthal avg at 256Γ—256 β†’ 128
9
+ ────
10
+ 1536 β†’ StandardScaler β†’ SVM (ONNX)
11
+
12
+ IMPORTANT: The SVM was trained on StandardScaler-normalized features.
13
+ scaler.pkl must be present in the backend folder.
14
+ Without it, predictions collapse to 0%/100%.
15
+
16
+ Threshold tuned to 0.35 based on independent test set analysis.
17
+
18
+ Run:
19
+ pip install flask flask-cors onnxruntime torch timm opencv-python-headless scipy numpy pillow scikit-learn
20
+ python app.py
21
+ """
22
+
23
+ from flask import Flask, request, jsonify
24
+ from flask_cors import CORS
25
+ import onnxruntime as ort
26
+ import numpy as np
27
+ import cv2
28
+ import io
29
+ import torch
30
+ import torch.nn as nn
31
+ import timm
32
+ from torchvision import transforms
33
+ from scipy import ndimage
34
+ from PIL import Image
35
+ import joblib
36
+ import os
37
+
38
+ app = Flask(__name__)
39
+ CORS(app)
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Config
43
+ # ---------------------------------------------------------------------------
44
+ FFT_SIZE = 256
45
+ AZ_BINS = FFT_SIZE // 2 # 128
46
+
47
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
48
+ CNN_WEIGHTS_PATH = os.path.join(BASE_DIR, "best_model.pth")
49
+ SVM_ONNX_PATH = os.path.join(BASE_DIR, "svm_linear_model.onnx")
50
+ SCALER_PATH = os.path.join(BASE_DIR, "scaler.pkl")
51
+
52
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
53
+
54
+ DECISION_THRESHOLD = 0.35
55
+ CALIBRATION_TEMPERATURE = 3.0
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # CNN feature extractor (4-channel EfficientNet-B0)
59
+ # ---------------------------------------------------------------------------
60
+ class DeepfakeDetector(nn.Module):
61
+ def __init__(self):
62
+ super().__init__()
63
+ self.backbone = timm.create_model("efficientnet_b0", pretrained=False, num_classes=0)
64
+ old_conv = self.backbone.conv_stem
65
+ new_conv = nn.Conv2d(
66
+ 4, old_conv.out_channels,
67
+ kernel_size=old_conv.kernel_size,
68
+ stride=old_conv.stride,
69
+ padding=old_conv.padding,
70
+ bias=old_conv.bias is not None,
71
+ )
72
+ self.backbone.conv_stem = new_conv
73
+ self.head = nn.Sequential(
74
+ nn.Dropout(0.3), nn.Linear(1280, 256), nn.ReLU(),
75
+ nn.Dropout(0.2), nn.Linear(256, 1),
76
+ )
77
+
78
+ def forward(self, x):
79
+ return self.backbone(x)
80
+
81
+
82
+ feature_model = DeepfakeDetector().to(DEVICE)
83
+ state = torch.load(CNN_WEIGHTS_PATH, map_location=DEVICE, weights_only=False)
84
+ feature_model.load_state_dict(state, strict=False)
85
+ feature_model.eval()
86
+ print("βœ… CNN feature extractor loaded")
87
+
88
+ svm_session = ort.InferenceSession(SVM_ONNX_PATH, providers=["CPUExecutionProvider"])
89
+ SVM_INPUT_NAME = svm_session.get_inputs()[0].name
90
+ print(f"βœ… SVM ONNX loaded β€” input '{SVM_INPUT_NAME}', expects {svm_session.get_inputs()[0].shape}")
91
+ print(f"βœ… Decision threshold: {DECISION_THRESHOLD} (lowered from default 0.5)")
92
+ print("\n--- SVM ONNX Outputs ---")
93
+ for out in svm_session.get_outputs():
94
+ print(f" name={out.name!r} shape={out.shape} type={out.type}")
95
+ print("------------------------\n")
96
+
97
+ if os.path.exists(SCALER_PATH):
98
+ feature_scaler = joblib.load(SCALER_PATH)
99
+ print(f"βœ… StandardScaler loaded from {SCALER_PATH}")
100
+ print(f" mean range: [{feature_scaler.mean_.min():.3f}, {feature_scaler.mean_.max():.3f}]")
101
+ print(f" scale range: [{feature_scaler.scale_.min():.3f}, {feature_scaler.scale_.max():.3f}]")
102
+ else:
103
+ feature_scaler = None
104
+ print(f"⚠️ WARNING: {SCALER_PATH} not found β€” predictions will be 0% or 100%!")
105
+
106
+ torch_transform = transforms.Compose([
107
+ transforms.ToPILImage(),
108
+ transforms.Resize((224, 224)),
109
+ transforms.ToTensor(),
110
+ transforms.Normalize(mean=[0.485, 0.456, 0.406],
111
+ std=[0.229, 0.224, 0.225]),
112
+ ])
113
+
114
+
115
+ def compute_azimuthal_average(spectrum_2d: np.ndarray) -> np.ndarray:
116
+ h, w = spectrum_2d.shape
117
+ cy, cx = h // 2, w // 2
118
+ Y, X = np.ogrid[:h, :w]
119
+ r = np.sqrt((X - cx) ** 2 + (Y - cy) ** 2).astype(int)
120
+ max_r = min(cy, cx)
121
+ return ndimage.mean(spectrum_2d, labels=r, index=np.arange(0, max_r))
122
+
123
+
124
+ def extract_combined_features(img_bgr: np.ndarray) -> np.ndarray:
125
+ img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
126
+ tensor_3ch = torch_transform(img_rgb)
127
+
128
+ gray224 = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
129
+ gray224 = cv2.resize(gray224, (224, 224)).astype(np.float32)
130
+ f = np.fft.fft2(gray224)
131
+ f_shift = np.fft.fftshift(f)
132
+ ps = np.log1p(np.abs(f_shift) ** 2)
133
+ ps_norm = (ps - ps.min()) / (ps.max() - ps.min() + 1e-8)
134
+ fft_ch = torch.tensor(ps_norm, dtype=torch.float32).unsqueeze(0)
135
+ tensor_4ch = torch.cat([tensor_3ch, fft_ch], dim=0).unsqueeze(0).to(DEVICE)
136
+
137
+ with torch.no_grad():
138
+ cnn_feat = feature_model(tensor_4ch).cpu().numpy().flatten()
139
+
140
+ gray = cv2.resize(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY),
141
+ (FFT_SIZE, FFT_SIZE)).astype(np.float32)
142
+
143
+ f2 = np.fft.fft2(gray)
144
+ f2_shift = np.fft.fftshift(f2)
145
+ power = np.log1p(np.abs(f2_shift) ** 2)
146
+ az_avg = compute_azimuthal_average(power)
147
+
148
+ blurred = cv2.GaussianBlur(gray, (5, 5), 1.0)
149
+ noise = gray - blurred
150
+ nf = np.fft.fft2(noise)
151
+ nf_shift = np.fft.fftshift(nf)
152
+ noise_power = np.log1p(np.abs(nf_shift) ** 2)
153
+ noise_az = compute_azimuthal_average(noise_power)
154
+
155
+ combined = np.concatenate([cnn_feat, az_avg, noise_az]).astype(np.float32)
156
+ assert combined.shape == (1536,), f"Bad feature dim: {combined.shape}"
157
+ return combined
158
+
159
+
160
+ def get_confidence_level(p_fake: float) -> str:
161
+ distance = abs(p_fake - DECISION_THRESHOLD)
162
+ if distance < 0.10:
163
+ return "uncertain"
164
+ elif distance < 0.25:
165
+ return "low"
166
+ elif distance < 0.40:
167
+ return "medium"
168
+ else:
169
+ return "high"
170
+
171
+
172
+ @app.route("/", methods=["GET"])
173
+ def health():
174
+ return jsonify({"status": "ok", "message": "Deepfake detector backend is running."})
175
+
176
+
177
+ @app.route("/analyze", methods=["POST"])
178
+ def analyze():
179
+ if "image" not in request.files:
180
+ return jsonify({"error": "No image"}), 400
181
+
182
+ try:
183
+ raw = request.files["image"].read()
184
+ pil = Image.open(io.BytesIO(raw)).convert("RGB")
185
+ img_rgb = np.array(pil)
186
+ img_bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)
187
+
188
+ features = extract_combined_features(img_bgr).reshape(1, -1)
189
+
190
+ if feature_scaler is not None:
191
+ features = feature_scaler.transform(features).astype(np.float32)
192
+
193
+ label_arr, prob_list = svm_session.run(None, {SVM_INPUT_NAME: features})
194
+ for out in svm_session.get_outputs():
195
+ print(out.name, out.shape, out.type)
196
+ prob_dict = prob_list[0]
197
+ p_fake_raw = float(prob_dict[1])
198
+
199
+ eps = 1e-6
200
+ p_clipped = float(np.clip(p_fake_raw, eps, 1.0 - eps))
201
+ logit_p = np.log(p_clipped / (1.0 - p_clipped))
202
+ p_fake = float(1.0 / (1.0 + np.exp(-logit_p / CALIBRATION_TEMPERATURE)))
203
+ p_real = 1.0 - p_fake
204
+
205
+ is_fake = bool(int(label_arr[0]) == 1)
206
+ confidence = get_confidence_level(p_fake)
207
+
208
+ display_percent = round(p_fake * 100, 2)
209
+ print(f"DEBUG: raw={p_fake_raw:.4f} temp_scaled={p_fake:.4f} "
210
+ f"label={'FAKE' if is_fake else 'REAL'} confidence={confidence}")
211
+
212
+ return jsonify({
213
+ "probability": display_percent,
214
+ "label": "AI Generated / Fake" if is_fake else "Authentic Media",
215
+ "is_fake": is_fake,
216
+ "confidence": confidence,
217
+ "p_real": round(p_real * 100, 2),
218
+ "p_fake": round(p_fake * 100, 2),
219
+ "threshold_used": DECISION_THRESHOLD,
220
+ })
221
+
222
+ except Exception as e:
223
+ import traceback; traceback.print_exc()
224
+ return jsonify({"error": f"Failed: {e}"}), 500
225
+
226
+
227
+ if __name__ == "__main__":
228
+ port = int(os.environ.get("PORT", 5000))
229
+ app.run(host="0.0.0.0", port=port, debug=False)
best_model.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5f4f2b8f38d45ae4db43f571308f1c005673dd47efb5d3725c6ae1485836f30a
3
+ size 17647947
check_weights.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ import timm
4
+
5
+ class DeepfakeDetector(nn.Module):
6
+ def __init__(self):
7
+ super().__init__()
8
+ self.backbone = timm.create_model("efficientnet_b0", pretrained=False, num_classes=0)
9
+ old_conv = self.backbone.conv_stem
10
+ new_conv = nn.Conv2d(4, old_conv.out_channels,
11
+ kernel_size=old_conv.kernel_size, stride=old_conv.stride,
12
+ padding=old_conv.padding, bias=old_conv.bias is not None)
13
+ self.backbone.conv_stem = new_conv
14
+ self.head = nn.Sequential(
15
+ nn.Dropout(0.3), nn.Linear(1280, 256), nn.ReLU(),
16
+ nn.Dropout(0.2), nn.Linear(256, 1))
17
+ def forward(self, x):
18
+ return self.backbone(x)
19
+
20
+ model = DeepfakeDetector()
21
+ state = torch.load("best_model.pth", map_location="cpu", weights_only=False)
22
+
23
+ print(f"Keys in checkpoint: {len(state)}")
24
+ print(f"Keys in model: {len(model.state_dict())}")
25
+
26
+ result = model.load_state_dict(state, strict=False)
27
+ print(f"\nMissing keys (not loaded, stay random!): {len(result.missing_keys)}")
28
+ for k in result.missing_keys[:20]:
29
+ print(f" {k}")
30
+
31
+ print(f"\nUnexpected keys (in file but unused): {len(result.unexpected_keys)}")
32
+ for k in result.unexpected_keys[:20]:
33
+ print(f" {k}")
34
+
35
+ # Check sample tensor for sanity
36
+ ckpt_first = list(state.keys())[0]
37
+ model_first = list(model.state_dict().keys())[0]
38
+ print(f"\nCheckpoint first key: {ckpt_first} shape={state[ckpt_first].shape}")
39
+ print(f"Model first key: {model_first} shape={model.state_dict()[model_first].shape}")
download_model.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ import os
3
+
4
+ # Link to the specific v2 ONNX model
5
+ url = "https://huggingface.co/onnx-community/Deep-Fake-Detector-v2-Model-ONNX/resolve/main/onnx/model.onnx"
6
+
7
+ def download_model():
8
+ print("Downloading Deep-Fake-Detector-v2 (ONNX)... This may take a moment.")
9
+ response = requests.get(url, stream=True)
10
+ with open("model.onnx", "wb") as f:
11
+ for chunk in response.iter_content(chunk_size=8192):
12
+ f.write(chunk)
13
+ print("Download Complete! 'model.onnx' is ready.")
14
+
15
+ if __name__ == "__main__":
16
+ download_model()
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ flask
2
+ flask-cors
3
+ onnxruntime
4
+ numpy
5
+ Pillow
6
+ scipy
7
+ torch
8
+ timm
9
+ opencv-python-headless
scaler.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d033f1eadf524df6115c85e801ca028efd6786849750bbb43d5fdc01251b412d
3
+ size 37479
svm_linear_model.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3903d7ded59104307e8c419bfb11c00914ee64a5e1609c80d3659bf45a4b32a3
3
+ size 1189973
testbatch.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Batch-test backend menggunakan satu folder dengan naming convention:
3
+ r1, r2, r3, ... β†’ real
4
+ f1, f2, f3, ... β†’ fake
5
+
6
+ Usage:
7
+ 1. Pastiin app.py jalan di terminal lain
8
+ 2. python test_batch.py "C:/Users/yourname/Downloads"
9
+ atau
10
+ python test_batch.py ~/Downloads
11
+ """
12
+ import os, sys, glob, re, requests
13
+ from collections import Counter
14
+
15
+ if len(sys.argv) < 2:
16
+ print("Usage: python test_batch.py /path/to/folder")
17
+ print('Windows example: python test_batch.py "C:\\Users\\YourName\\Downloads"')
18
+ print("Mac example: python test_batch.py ~/Downloads")
19
+ sys.exit(1)
20
+
21
+ folder = os.path.expanduser(sys.argv[1])
22
+ URL = "http://localhost:5000/analyze"
23
+
24
+ if not os.path.isdir(folder):
25
+ print(f"❌ Folder tidak ditemukan: {folder}")
26
+ sys.exit(1)
27
+
28
+ # Cari semua gambar
29
+ EXTS = ["jpg", "jpeg", "png", "webp"]
30
+ all_files = []
31
+ for ext in EXTS:
32
+ all_files.extend(glob.glob(os.path.join(folder, f"*.{ext}")))
33
+ all_files.extend(glob.glob(os.path.join(folder, f"*.{ext.upper()}")))
34
+
35
+ # Klasifikasi berdasarkan prefix nama file (r1.jpg β†’ real, f1.jpg β†’ fake)
36
+ pattern = re.compile(r"^([rf])(\d+)\.", re.IGNORECASE)
37
+ labeled_files = []
38
+ for fp in all_files:
39
+ name = os.path.basename(fp)
40
+ m = pattern.match(name)
41
+ if m:
42
+ prefix = m.group(1).lower()
43
+ true_label = "real" if prefix == "r" else "fake"
44
+ idx = int(m.group(2))
45
+ labeled_files.append((fp, true_label, idx))
46
+
47
+ if not labeled_files:
48
+ print(f"❌ Gak ada file dengan format r1.jpg / f1.jpg dst di {folder}")
49
+ print(" File yang ada di folder:")
50
+ for fp in sorted(all_files)[:10]:
51
+ print(f" {os.path.basename(fp)}")
52
+ sys.exit(1)
53
+
54
+ # Sort: real dulu (sesuai index), terus fake
55
+ labeled_files.sort(key=lambda x: (x[1], x[2]))
56
+ print(f"βœ… Ditemukan {len(labeled_files)} gambar berlabel di {folder}\n")
57
+
58
+ stats = Counter()
59
+ mistakes = []
60
+ current_label = None
61
+
62
+ for fp, true_label, idx in labeled_files:
63
+ # Print header tiap ganti kategori
64
+ if true_label != current_label:
65
+ count = sum(1 for _, t, _ in labeled_files if t == true_label)
66
+ print(f"\n=== {true_label.upper()} ({count} files) ===")
67
+ print(f"{'file':<25} {'pred':<6} {'p_fake':>9} {'ok'}")
68
+ print("-" * 55)
69
+ current_label = true_label
70
+
71
+ try:
72
+ with open(fp, "rb") as f:
73
+ r = requests.post(URL, files={"image": f}, timeout=30)
74
+ except requests.exceptions.ConnectionError:
75
+ print(f"\n❌ Backend gak nyala di {URL}")
76
+ print(" Jalanin dulu: python app.py")
77
+ sys.exit(1)
78
+
79
+ if r.status_code != 200:
80
+ print(f" ERROR on {fp}: {r.text}")
81
+ continue
82
+
83
+ d = r.json()
84
+ pred = "fake" if d["is_fake"] else "real"
85
+ ok = pred == true_label
86
+ stats[(true_label, pred)] += 1
87
+ if not ok:
88
+ mistakes.append((fp, true_label, pred, d["p_fake"]))
89
+ mark = "OK" if ok else "WRONG"
90
+ name = os.path.basename(fp)[:23]
91
+ print(f"{name:<25} {pred:<6} {d['p_fake']:>8.2f}% {mark}")
92
+
93
+ # Summary
94
+ print("\n" + "=" * 55)
95
+ print("CONFUSION MATRIX")
96
+ print("=" * 55)
97
+ print(f"{'':<10} {'pred_real':>12} {'pred_fake':>12}")
98
+ print(f"{'true_real':<10} {stats[('real','real')]:>12} {stats[('real','fake')]:>12}")
99
+ print(f"{'true_fake':<10} {stats[('fake','real')]:>12} {stats[('fake','fake')]:>12}")
100
+
101
+ total = sum(stats.values())
102
+ correct = stats[("real","real")] + stats[("fake","fake")]
103
+ if total:
104
+ print(f"\nAccuracy: {correct}/{total} = {100*correct/total:.1f}%")
105
+
106
+ n_real = stats[("real","real")] + stats[("real","fake")]
107
+ n_fake = stats[("fake","real")] + stats[("fake","fake")]
108
+ if n_real:
109
+ print(f"Real recall: {stats[('real','real')]}/{n_real} = {100*stats[('real','real')]/n_real:.1f}%")
110
+ if n_fake:
111
+ print(f"Fake recall: {stats[('fake','fake')]}/{n_fake} = {100*stats[('fake','fake')]/n_fake:.1f}%")
112
+
113
+ if mistakes:
114
+ print(f"\n{len(mistakes)} MISCLASSIFIED:")
115
+ for fp, t, p, pf in mistakes:
116
+ print(f" {os.path.basename(fp):<25} true={t:<5} pred={p:<5} p_fake={pf:.2f}%")