Spaces:
Sleeping
Sleeping
Deploy FN Detector backend
Browse files- app.py +101 -8
- deployment/deepfake_model/bundle.json +105 -0
- requirements.txt +2 -0
- src/fake_news_detector/deepfake_detection.py +257 -0
- src/fake_news_detector/prediction.py +4 -0
app.py
CHANGED
|
@@ -2,7 +2,7 @@ from pathlib import Path
|
|
| 2 |
import os
|
| 3 |
import sys
|
| 4 |
|
| 5 |
-
from fastapi import FastAPI, HTTPException
|
| 6 |
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
from pydantic import BaseModel, Field
|
| 8 |
|
|
@@ -12,6 +12,10 @@ SRC_DIR = ROOT / "src"
|
|
| 12 |
if str(SRC_DIR) not in sys.path:
|
| 13 |
sys.path.insert(0, str(SRC_DIR))
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
from fake_news_detector.prediction import ( # noqa: E402
|
| 16 |
combine_news_text,
|
| 17 |
get_model_snapshot,
|
|
@@ -29,6 +33,7 @@ def _parse_cors_origins() -> list[str]:
|
|
| 29 |
|
| 30 |
|
| 31 |
MODEL_DIR = ROOT / os.environ.get("MODEL_DIR", "deployment/model")
|
|
|
|
| 32 |
|
| 33 |
app = FastAPI(
|
| 34 |
title="FN Detector API",
|
|
@@ -56,12 +61,25 @@ def root() -> dict:
|
|
| 56 |
"service": "fn-detector-api",
|
| 57 |
"status": "online",
|
| 58 |
"model_dir": str(MODEL_DIR),
|
| 59 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
}
|
| 61 |
|
| 62 |
|
| 63 |
-
|
| 64 |
-
def health() -> dict:
|
| 65 |
try:
|
| 66 |
snapshot = get_model_snapshot(MODEL_DIR)
|
| 67 |
except FileNotFoundError as exc:
|
|
@@ -78,8 +96,13 @@ def health() -> dict:
|
|
| 78 |
}
|
| 79 |
|
| 80 |
|
| 81 |
-
@app.get("/
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
try:
|
| 84 |
return {
|
| 85 |
"status": "ok",
|
|
@@ -93,8 +116,13 @@ def metrics() -> dict:
|
|
| 93 |
raise HTTPException(status_code=500, detail=f"Unable to read metrics: {exc}") from exc
|
| 94 |
|
| 95 |
|
| 96 |
-
@app.
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
combined_text = combine_news_text(request.title, request.text)
|
| 99 |
if not combined_text:
|
| 100 |
raise HTTPException(status_code=400, detail="Please provide a title or article body.")
|
|
@@ -117,3 +145,68 @@ def predict(request: PredictionRequest) -> dict:
|
|
| 117 |
},
|
| 118 |
"prediction": prediction,
|
| 119 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import os
|
| 3 |
import sys
|
| 4 |
|
| 5 |
+
from fastapi import FastAPI, File, HTTPException, UploadFile
|
| 6 |
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
from pydantic import BaseModel, Field
|
| 8 |
|
|
|
|
| 12 |
if str(SRC_DIR) not in sys.path:
|
| 13 |
sys.path.insert(0, str(SRC_DIR))
|
| 14 |
|
| 15 |
+
from fake_news_detector.deepfake_detection import ( # noqa: E402
|
| 16 |
+
get_deepfake_model_snapshot,
|
| 17 |
+
predict_deepfake_image,
|
| 18 |
+
)
|
| 19 |
from fake_news_detector.prediction import ( # noqa: E402
|
| 20 |
combine_news_text,
|
| 21 |
get_model_snapshot,
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
MODEL_DIR = ROOT / os.environ.get("MODEL_DIR", "deployment/model")
|
| 36 |
+
DEEPFAKE_MODEL_DIR = ROOT / os.environ.get("DEEPFAKE_MODEL_DIR", "deployment/deepfake_model")
|
| 37 |
|
| 38 |
app = FastAPI(
|
| 39 |
title="FN Detector API",
|
|
|
|
| 61 |
"service": "fn-detector-api",
|
| 62 |
"status": "online",
|
| 63 |
"model_dir": str(MODEL_DIR),
|
| 64 |
+
"deepfake_model_dir": str(DEEPFAKE_MODEL_DIR),
|
| 65 |
+
"routes": [
|
| 66 |
+
"/health",
|
| 67 |
+
"/metrics",
|
| 68 |
+
"/predict",
|
| 69 |
+
"/deepfake/health",
|
| 70 |
+
"/deepfake/metrics",
|
| 71 |
+
"/deepfake/predict",
|
| 72 |
+
"/api/health",
|
| 73 |
+
"/api/metrics",
|
| 74 |
+
"/api/predict",
|
| 75 |
+
"/api/deepfake/health",
|
| 76 |
+
"/api/deepfake/metrics",
|
| 77 |
+
"/api/deepfake/predict",
|
| 78 |
+
],
|
| 79 |
}
|
| 80 |
|
| 81 |
|
| 82 |
+
def _news_health() -> dict:
|
|
|
|
| 83 |
try:
|
| 84 |
snapshot = get_model_snapshot(MODEL_DIR)
|
| 85 |
except FileNotFoundError as exc:
|
|
|
|
| 96 |
}
|
| 97 |
|
| 98 |
|
| 99 |
+
@app.get("/health")
|
| 100 |
+
@app.get("/api/health")
|
| 101 |
+
def health() -> dict:
|
| 102 |
+
return _news_health()
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _news_metrics() -> dict:
|
| 106 |
try:
|
| 107 |
return {
|
| 108 |
"status": "ok",
|
|
|
|
| 116 |
raise HTTPException(status_code=500, detail=f"Unable to read metrics: {exc}") from exc
|
| 117 |
|
| 118 |
|
| 119 |
+
@app.get("/metrics")
|
| 120 |
+
@app.get("/api/metrics")
|
| 121 |
+
def metrics() -> dict:
|
| 122 |
+
return _news_metrics()
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _news_predict(request: PredictionRequest) -> dict:
|
| 126 |
combined_text = combine_news_text(request.title, request.text)
|
| 127 |
if not combined_text:
|
| 128 |
raise HTTPException(status_code=400, detail="Please provide a title or article body.")
|
|
|
|
| 145 |
},
|
| 146 |
"prediction": prediction,
|
| 147 |
}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@app.post("/predict")
|
| 151 |
+
@app.post("/api/predict")
|
| 152 |
+
def predict(request: PredictionRequest) -> dict:
|
| 153 |
+
return _news_predict(request)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _deepfake_health() -> dict:
|
| 157 |
+
try:
|
| 158 |
+
snapshot = get_deepfake_model_snapshot(DEEPFAKE_MODEL_DIR)
|
| 159 |
+
except FileNotFoundError as exc:
|
| 160 |
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
| 161 |
+
return {
|
| 162 |
+
"status": "ok",
|
| 163 |
+
"service": "fn-detector-api",
|
| 164 |
+
"deepfake_model": snapshot["summary"].get("model_name"),
|
| 165 |
+
"mode": snapshot["summary"].get("status"),
|
| 166 |
+
"feature_count": len(snapshot["feature_names"]),
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@app.get("/deepfake/health")
|
| 171 |
+
@app.get("/api/deepfake/health")
|
| 172 |
+
def deepfake_health() -> dict:
|
| 173 |
+
return _deepfake_health()
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _deepfake_metrics() -> dict:
|
| 177 |
+
try:
|
| 178 |
+
return {
|
| 179 |
+
"status": "ok",
|
| 180 |
+
"snapshot": get_deepfake_model_snapshot(DEEPFAKE_MODEL_DIR),
|
| 181 |
+
}
|
| 182 |
+
except FileNotFoundError as exc:
|
| 183 |
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@app.get("/deepfake/metrics")
|
| 187 |
+
@app.get("/api/deepfake/metrics")
|
| 188 |
+
def deepfake_metrics() -> dict:
|
| 189 |
+
return _deepfake_metrics()
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _deepfake_predict(filename: str, payload: bytes) -> dict:
|
| 193 |
+
try:
|
| 194 |
+
prediction = predict_deepfake_image(
|
| 195 |
+
model_dir=DEEPFAKE_MODEL_DIR,
|
| 196 |
+
image_bytes=payload,
|
| 197 |
+
filename=filename,
|
| 198 |
+
)
|
| 199 |
+
except ValueError as exc:
|
| 200 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 201 |
+
except FileNotFoundError as exc:
|
| 202 |
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
| 203 |
+
except Exception as exc: # pragma: no cover - defensive for deployment only
|
| 204 |
+
raise HTTPException(status_code=500, detail=f"Deepfake prediction failed: {exc}") from exc
|
| 205 |
+
|
| 206 |
+
return {"status": "ok", "prediction": prediction}
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@app.post("/deepfake/predict")
|
| 210 |
+
@app.post("/api/deepfake/predict")
|
| 211 |
+
async def deepfake_predict(file: UploadFile = File(...)) -> dict:
|
| 212 |
+
return _deepfake_predict(file.filename or "upload", await file.read())
|
deployment/deepfake_model/bundle.json
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"feature_names": [
|
| 3 |
+
"gray_mean",
|
| 4 |
+
"gray_std",
|
| 5 |
+
"laplacian_var",
|
| 6 |
+
"gradient_mean",
|
| 7 |
+
"gradient_std",
|
| 8 |
+
"high_frequency_ratio",
|
| 9 |
+
"blockiness",
|
| 10 |
+
"jpeg_residual_mean",
|
| 11 |
+
"jpeg_residual_std",
|
| 12 |
+
"mirror_difference",
|
| 13 |
+
"saturation_mean",
|
| 14 |
+
"saturation_std"
|
| 15 |
+
],
|
| 16 |
+
"scaler": {
|
| 17 |
+
"mean": [
|
| 18 |
+
0.3894135057926178,
|
| 19 |
+
0.16643017530441284,
|
| 20 |
+
0.0016245938604697585,
|
| 21 |
+
0.01306572463363409,
|
| 22 |
+
0.0241470355540514,
|
| 23 |
+
0.30300813913345337,
|
| 24 |
+
1.007244348526001,
|
| 25 |
+
0.008029050193727016,
|
| 26 |
+
0.007909948006272316,
|
| 27 |
+
0.11933569610118866,
|
| 28 |
+
0.17327596247196198,
|
| 29 |
+
0.07436472177505493
|
| 30 |
+
],
|
| 31 |
+
"scale": [
|
| 32 |
+
0.0831027701497078,
|
| 33 |
+
0.04393522068858147,
|
| 34 |
+
0.0009263058891519904,
|
| 35 |
+
0.004046801012009382,
|
| 36 |
+
0.006974863354116678,
|
| 37 |
+
0.03189605101943016,
|
| 38 |
+
0.08480057865381241,
|
| 39 |
+
0.001521392841823399,
|
| 40 |
+
0.0017849425785243511,
|
| 41 |
+
0.06415991485118866,
|
| 42 |
+
0.04083705693483353,
|
| 43 |
+
0.032066166400909424
|
| 44 |
+
]
|
| 45 |
+
},
|
| 46 |
+
"model": {
|
| 47 |
+
"coefficients": [
|
| 48 |
+
-0.1552580323,
|
| 49 |
+
0.4495079971,
|
| 50 |
+
-0.1030570684,
|
| 51 |
+
0.6508357217,
|
| 52 |
+
-0.2056009272,
|
| 53 |
+
-0.4020637292,
|
| 54 |
+
-0.1068058709,
|
| 55 |
+
-0.3841619248,
|
| 56 |
+
-0.6691807026,
|
| 57 |
+
0.9157355483,
|
| 58 |
+
-1.0115699767,
|
| 59 |
+
0.3580950632
|
| 60 |
+
],
|
| 61 |
+
"intercept": 0.0585737738
|
| 62 |
+
},
|
| 63 |
+
"training_summary": {
|
| 64 |
+
"status": "trained",
|
| 65 |
+
"model_name": "lightweight-deepfake-linear",
|
| 66 |
+
"dataset_dir": "C:\\Users\\harma\\OneDrive\\Desktop\\FN\\FNdetector\\external\\DeepFake-Detect\\prepared_dataset",
|
| 67 |
+
"dataset_rows": 22,
|
| 68 |
+
"train_rows": 16,
|
| 69 |
+
"test_rows": 6,
|
| 70 |
+
"max_images_per_label": 32,
|
| 71 |
+
"accuracy": 1.0,
|
| 72 |
+
"classification_report": {
|
| 73 |
+
"real": {
|
| 74 |
+
"precision": 1.0,
|
| 75 |
+
"recall": 1.0,
|
| 76 |
+
"f1-score": 1.0,
|
| 77 |
+
"support": 3.0
|
| 78 |
+
},
|
| 79 |
+
"fake": {
|
| 80 |
+
"precision": 1.0,
|
| 81 |
+
"recall": 1.0,
|
| 82 |
+
"f1-score": 1.0,
|
| 83 |
+
"support": 3.0
|
| 84 |
+
},
|
| 85 |
+
"accuracy": 1.0,
|
| 86 |
+
"macro avg": {
|
| 87 |
+
"precision": 1.0,
|
| 88 |
+
"recall": 1.0,
|
| 89 |
+
"f1-score": 1.0,
|
| 90 |
+
"support": 6.0
|
| 91 |
+
},
|
| 92 |
+
"weighted avg": {
|
| 93 |
+
"precision": 1.0,
|
| 94 |
+
"recall": 1.0,
|
| 95 |
+
"f1-score": 1.0,
|
| 96 |
+
"support": 6.0
|
| 97 |
+
}
|
| 98 |
+
},
|
| 99 |
+
"system_profile": {
|
| 100 |
+
"cpu": "Intel Core i7-8550U class",
|
| 101 |
+
"ram_gb": 16,
|
| 102 |
+
"recommended_reason": "Conservative cap to avoid memory spikes on laptop-class CPUs."
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
}
|
requirements.txt
CHANGED
|
@@ -3,3 +3,5 @@ transformers>=4.51,<5.0
|
|
| 3 |
safetensors>=0.5,<1.0
|
| 4 |
fastapi>=0.117,<1.0
|
| 5 |
uvicorn>=0.35,<1.0
|
|
|
|
|
|
|
|
|
| 3 |
safetensors>=0.5,<1.0
|
| 4 |
fastapi>=0.117,<1.0
|
| 5 |
uvicorn>=0.35,<1.0
|
| 6 |
+
python-multipart>=0.0.20,<1.0
|
| 7 |
+
Pillow>=11.2,<12.0
|
src/fake_news_detector/deepfake_detection.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from functools import lru_cache
|
| 5 |
+
import io
|
| 6 |
+
import json
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
from PIL import Image
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
IMAGE_SIZE = 128
|
| 14 |
+
MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
| 15 |
+
FEATURE_NAMES = [
|
| 16 |
+
"gray_mean",
|
| 17 |
+
"gray_std",
|
| 18 |
+
"laplacian_var",
|
| 19 |
+
"gradient_mean",
|
| 20 |
+
"gradient_std",
|
| 21 |
+
"high_frequency_ratio",
|
| 22 |
+
"blockiness",
|
| 23 |
+
"jpeg_residual_mean",
|
| 24 |
+
"jpeg_residual_std",
|
| 25 |
+
"mirror_difference",
|
| 26 |
+
"saturation_mean",
|
| 27 |
+
"saturation_std",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass(frozen=True)
|
| 32 |
+
class DeepfakeModelBundle:
|
| 33 |
+
feature_names: tuple[str, ...]
|
| 34 |
+
mean: np.ndarray
|
| 35 |
+
scale: np.ndarray
|
| 36 |
+
coefficients: np.ndarray
|
| 37 |
+
intercept: float
|
| 38 |
+
training_summary: dict
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _sigmoid(value: float) -> float:
|
| 42 |
+
return float(1.0 / (1.0 + np.exp(-value)))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _resolve_model_dir(model_dir: Path) -> Path:
|
| 46 |
+
resolved = Path(model_dir).resolve()
|
| 47 |
+
if not resolved.exists():
|
| 48 |
+
raise FileNotFoundError(f"Deepfake model directory does not exist: {resolved}")
|
| 49 |
+
return resolved
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _load_image(source: bytes) -> Image.Image:
|
| 53 |
+
if not source:
|
| 54 |
+
raise ValueError("No image content was provided.")
|
| 55 |
+
if len(source) > MAX_IMAGE_BYTES:
|
| 56 |
+
raise ValueError("Image is too large. Please upload an image under 8 MB.")
|
| 57 |
+
try:
|
| 58 |
+
image = Image.open(io.BytesIO(source))
|
| 59 |
+
except Exception as exc: # pragma: no cover - defensive for malformed uploads
|
| 60 |
+
raise ValueError("Unable to decode the uploaded image.") from exc
|
| 61 |
+
return image.convert("RGB")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _prepare_rgb_array(image: Image.Image) -> np.ndarray:
|
| 65 |
+
resized = image.resize((IMAGE_SIZE, IMAGE_SIZE), Image.Resampling.BILINEAR)
|
| 66 |
+
return np.asarray(resized, dtype=np.float32) / 255.0
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _compute_gray(rgb: np.ndarray) -> np.ndarray:
|
| 70 |
+
return np.dot(rgb[..., :3], np.array([0.2989, 0.5870, 0.1140], dtype=np.float32))
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _compute_laplacian(gray: np.ndarray) -> np.ndarray:
|
| 74 |
+
center = gray[1:-1, 1:-1]
|
| 75 |
+
return (
|
| 76 |
+
gray[:-2, 1:-1]
|
| 77 |
+
+ gray[2:, 1:-1]
|
| 78 |
+
+ gray[1:-1, :-2]
|
| 79 |
+
+ gray[1:-1, 2:]
|
| 80 |
+
- (4.0 * center)
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _compute_frequency_ratio(gray: np.ndarray) -> float:
|
| 85 |
+
centered = gray - float(gray.mean())
|
| 86 |
+
spectrum = np.abs(np.fft.rfft2(centered))
|
| 87 |
+
if not np.any(spectrum):
|
| 88 |
+
return 0.0
|
| 89 |
+
|
| 90 |
+
height, width = gray.shape
|
| 91 |
+
y_coords = np.fft.fftfreq(height)[:, None]
|
| 92 |
+
x_coords = np.fft.rfftfreq(width)[None, :]
|
| 93 |
+
radius = np.sqrt((y_coords**2) + (x_coords**2))
|
| 94 |
+
high_mask = radius >= 0.18
|
| 95 |
+
high_energy = float(spectrum[high_mask].sum())
|
| 96 |
+
total_energy = float(spectrum.sum()) + 1e-8
|
| 97 |
+
return high_energy / total_energy
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _compute_blockiness(gray: np.ndarray) -> float:
|
| 101 |
+
vertical_boundaries = gray[:, 8::8] - gray[:, 7:-1:8]
|
| 102 |
+
horizontal_boundaries = gray[8::8, :] - gray[7:-1:8, :]
|
| 103 |
+
all_vertical = np.diff(gray, axis=1)
|
| 104 |
+
all_horizontal = np.diff(gray, axis=0)
|
| 105 |
+
boundary_energy = float(np.mean(np.abs(vertical_boundaries))) + float(
|
| 106 |
+
np.mean(np.abs(horizontal_boundaries))
|
| 107 |
+
)
|
| 108 |
+
overall_energy = float(np.mean(np.abs(all_vertical))) + float(np.mean(np.abs(all_horizontal))) + 1e-8
|
| 109 |
+
return boundary_energy / overall_energy
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _compute_jpeg_residual(rgb_uint8: np.ndarray) -> tuple[float, float]:
|
| 113 |
+
image = Image.fromarray(rgb_uint8, mode="RGB")
|
| 114 |
+
buffer = io.BytesIO()
|
| 115 |
+
image.save(buffer, format="JPEG", quality=72, optimize=True)
|
| 116 |
+
recompressed = Image.open(io.BytesIO(buffer.getvalue())).convert("RGB")
|
| 117 |
+
diff = np.abs(rgb_uint8.astype(np.float32) - np.asarray(recompressed, dtype=np.float32)) / 255.0
|
| 118 |
+
return float(diff.mean()), float(diff.std())
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _compute_saturation(rgb: np.ndarray) -> np.ndarray:
|
| 122 |
+
channel_max = rgb.max(axis=2)
|
| 123 |
+
channel_min = rgb.min(axis=2)
|
| 124 |
+
return channel_max - channel_min
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def extract_feature_dict(image: Image.Image) -> dict[str, float]:
|
| 128 |
+
rgb = _prepare_rgb_array(image)
|
| 129 |
+
rgb_uint8 = np.clip(np.round(rgb * 255.0), 0, 255).astype(np.uint8)
|
| 130 |
+
gray = _compute_gray(rgb)
|
| 131 |
+
laplacian = _compute_laplacian(gray)
|
| 132 |
+
gradients = np.concatenate(
|
| 133 |
+
[
|
| 134 |
+
np.abs(np.diff(gray, axis=0)).ravel(),
|
| 135 |
+
np.abs(np.diff(gray, axis=1)).ravel(),
|
| 136 |
+
]
|
| 137 |
+
)
|
| 138 |
+
saturation = _compute_saturation(rgb)
|
| 139 |
+
mirrored = np.flip(rgb, axis=1)
|
| 140 |
+
jpeg_mean, jpeg_std = _compute_jpeg_residual(rgb_uint8)
|
| 141 |
+
|
| 142 |
+
values = {
|
| 143 |
+
"gray_mean": float(gray.mean()),
|
| 144 |
+
"gray_std": float(gray.std()),
|
| 145 |
+
"laplacian_var": float(laplacian.var()),
|
| 146 |
+
"gradient_mean": float(gradients.mean()),
|
| 147 |
+
"gradient_std": float(gradients.std()),
|
| 148 |
+
"high_frequency_ratio": _compute_frequency_ratio(gray),
|
| 149 |
+
"blockiness": _compute_blockiness(gray),
|
| 150 |
+
"jpeg_residual_mean": jpeg_mean,
|
| 151 |
+
"jpeg_residual_std": jpeg_std,
|
| 152 |
+
"mirror_difference": float(np.mean(np.abs(rgb - mirrored))),
|
| 153 |
+
"saturation_mean": float(saturation.mean()),
|
| 154 |
+
"saturation_std": float(saturation.std()),
|
| 155 |
+
}
|
| 156 |
+
return values
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def extract_feature_vector(image: Image.Image) -> np.ndarray:
|
| 160 |
+
feature_dict = extract_feature_dict(image)
|
| 161 |
+
return np.array([feature_dict[name] for name in FEATURE_NAMES], dtype=np.float32)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _default_training_summary(model_dir: Path) -> dict:
|
| 165 |
+
return {
|
| 166 |
+
"status": "heuristic",
|
| 167 |
+
"model_name": "artifact-heuristic-baseline",
|
| 168 |
+
"model_dir": str(model_dir),
|
| 169 |
+
"notes": "No trained lightweight deepfake bundle was found, so the fallback heuristic is active.",
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
@lru_cache(maxsize=2)
|
| 174 |
+
def load_deepfake_bundle(model_dir_value: str) -> DeepfakeModelBundle | None:
|
| 175 |
+
model_dir = _resolve_model_dir(Path(model_dir_value))
|
| 176 |
+
bundle_path = model_dir / "bundle.json"
|
| 177 |
+
if not bundle_path.exists():
|
| 178 |
+
return None
|
| 179 |
+
|
| 180 |
+
payload = json.loads(bundle_path.read_text(encoding="utf-8"))
|
| 181 |
+
return DeepfakeModelBundle(
|
| 182 |
+
feature_names=tuple(payload.get("feature_names", FEATURE_NAMES)),
|
| 183 |
+
mean=np.array(payload["scaler"]["mean"], dtype=np.float32),
|
| 184 |
+
scale=np.array(payload["scaler"]["scale"], dtype=np.float32),
|
| 185 |
+
coefficients=np.array(payload["model"]["coefficients"], dtype=np.float32),
|
| 186 |
+
intercept=float(payload["model"]["intercept"]),
|
| 187 |
+
training_summary=payload.get("training_summary", {}),
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def get_deepfake_model_snapshot(model_dir: Path) -> dict:
|
| 192 |
+
resolved = _resolve_model_dir(model_dir)
|
| 193 |
+
bundle = load_deepfake_bundle(str(resolved))
|
| 194 |
+
summary = bundle.training_summary if bundle else _default_training_summary(resolved)
|
| 195 |
+
|
| 196 |
+
return {
|
| 197 |
+
"model_dir": str(resolved),
|
| 198 |
+
"available": bundle is not None,
|
| 199 |
+
"feature_names": FEATURE_NAMES,
|
| 200 |
+
"summary": summary,
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _heuristic_prediction(features: dict[str, float]) -> float:
|
| 205 |
+
raw_score = (
|
| 206 |
+
2.8 * features["jpeg_residual_mean"]
|
| 207 |
+
+ 1.6 * features["high_frequency_ratio"]
|
| 208 |
+
+ 0.7 * max(features["blockiness"] - 1.0, 0.0)
|
| 209 |
+
+ 1.4 * features["mirror_difference"]
|
| 210 |
+
+ 8.0 * features["laplacian_var"]
|
| 211 |
+
- 0.6 * features["saturation_mean"]
|
| 212 |
+
)
|
| 213 |
+
centered = (raw_score - 0.22) * 4.0
|
| 214 |
+
return min(max(_sigmoid(centered), 0.02), 0.98)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def predict_deepfake_image(model_dir: Path, image_bytes: bytes, filename: str | None = None) -> dict:
|
| 218 |
+
image = _load_image(image_bytes)
|
| 219 |
+
features = extract_feature_dict(image)
|
| 220 |
+
vector = np.array([features[name] for name in FEATURE_NAMES], dtype=np.float32)
|
| 221 |
+
|
| 222 |
+
bundle = load_deepfake_bundle(str(_resolve_model_dir(model_dir)))
|
| 223 |
+
if bundle is None:
|
| 224 |
+
fake_score = _heuristic_prediction(features)
|
| 225 |
+
model_name = "artifact-heuristic-baseline"
|
| 226 |
+
model_status = "heuristic"
|
| 227 |
+
else:
|
| 228 |
+
normalized = (vector - bundle.mean) / np.where(bundle.scale == 0, 1.0, bundle.scale)
|
| 229 |
+
logit = float(np.dot(normalized, bundle.coefficients) + bundle.intercept)
|
| 230 |
+
fake_score = _sigmoid(logit)
|
| 231 |
+
model_name = str(bundle.training_summary.get("model_name", "lightweight-deepfake-linear"))
|
| 232 |
+
model_status = "trained"
|
| 233 |
+
|
| 234 |
+
real_score = 1.0 - fake_score
|
| 235 |
+
top_feature_names = sorted(
|
| 236 |
+
FEATURE_NAMES,
|
| 237 |
+
key=lambda feature_name: abs(features[feature_name]),
|
| 238 |
+
reverse=True,
|
| 239 |
+
)[:4]
|
| 240 |
+
|
| 241 |
+
return {
|
| 242 |
+
"prediction": "fake" if fake_score >= 0.5 else "real",
|
| 243 |
+
"confidence": float(max(fake_score, real_score)),
|
| 244 |
+
"scores": {
|
| 245 |
+
"fake": float(fake_score),
|
| 246 |
+
"real": float(real_score),
|
| 247 |
+
},
|
| 248 |
+
"model_name": model_name,
|
| 249 |
+
"model_status": model_status,
|
| 250 |
+
"filename": filename or "upload",
|
| 251 |
+
"image_size": {"width": image.width, "height": image.height},
|
| 252 |
+
"features": features,
|
| 253 |
+
"top_signals": [
|
| 254 |
+
{"name": name, "value": float(features[name])}
|
| 255 |
+
for name in top_feature_names
|
| 256 |
+
],
|
| 257 |
+
}
|
src/fake_news_detector/prediction.py
CHANGED
|
@@ -2,8 +2,12 @@ from dataclasses import dataclass
|
|
| 2 |
from functools import lru_cache
|
| 3 |
from pathlib import Path
|
| 4 |
import json
|
|
|
|
| 5 |
import re
|
| 6 |
|
|
|
|
|
|
|
|
|
|
| 7 |
import torch
|
| 8 |
from transformers import (
|
| 9 |
AutoModelForSequenceClassification,
|
|
|
|
| 2 |
from functools import lru_cache
|
| 3 |
from pathlib import Path
|
| 4 |
import json
|
| 5 |
+
import os
|
| 6 |
import re
|
| 7 |
|
| 8 |
+
os.environ.setdefault("TRANSFORMERS_NO_TF", "1")
|
| 9 |
+
os.environ.setdefault("USE_TF", "0")
|
| 10 |
+
|
| 11 |
import torch
|
| 12 |
from transformers import (
|
| 13 |
AutoModelForSequenceClassification,
|