RealvsScreen / predict.py
anakvyas's picture
Deploy: app, model, and frontend only (excludes training dataset)
e63d782
Raw
History Blame Contribute Delete
6.43 kB
"""
Real Photo vs Screen-Recapture Detector β€” Predictor
====================================================
Score: 0.0 = real photo | 1.0 = screen recapture (photo of a screen)
Usage: python predict.py path/to/image.jpg
"""
import sys, torch, torch.nn as nn
import numpy as np
from PIL import Image
MODEL_PATH = "model.pt"
N_FEATURES = 9
CAL_MEAN = np.array([
0.657936574479695,
2745.1976744186045,
2845.6976744186045,
1.0338196523414849,
3264.8837209302324,
2326.0116279069766,
5.97093023255814,
0.6744186046511628,
0.313953488372093,
], dtype=np.float32)
CAL_STD = np.array([
0.29900749167420493,
1182.2560953182885,
1166.97805094968,
0.4255359545715053,
1174.7696312482435,
971.4902371498841,
8.814251700640252,
0.46859266696767204,
0.46409872194128204,
], dtype=np.float32)
CAL_WEIGHTS = np.array([
1.4671577078679652,
-0.22012156472297992,
-0.37561977398197904,
0.12766481098597754,
-0.40954458454579995,
-0.22384121266702633,
0.5954392718655914,
1.3534821530033514,
0.6008533472740771,
], dtype=np.float32)
CAL_BIAS = 1.5609243085485869
COMMON_SCREEN_SIZES = {(3072, 4096), (4096, 3072), (900, 1600), (1600, 900)}
# ──────────────────────────────────────────────────────────────────────────────
# FEATURES (must match train.py exactly)
# ──────────────────────────────────────────────────────────────────────────────
def extract_features(path):
img = Image.open(path).convert("RGB").resize((224, 224))
arr = np.array(img, dtype=np.float32) / 255.0
R, G, B = arr[:,:,0], arr[:,:,1], arr[:,:,2]
hsv = np.array(img.convert("HSV"), dtype=np.float32) / 255.0
S, V = hsv[:,:,1], hsv[:,:,2]
blue_red = B.mean() / (R.mean() + 1e-6)
sat_mean = S.mean()
sat_std = S.std()
sat_p75 = float(np.percentile(S, 75))
bri_std = V.std()
gy = np.abs(V[1:, :] - V[:-1, :]).mean()
gx = np.abs(V[:, 1:] - V[:, :-1]).mean()
edge_ratio = (gx + gy) / (bri_std + 1e-6)
def bezel_ratio(e):
border = np.concatenate([V[:e, :].ravel(), V[-e:, :].ravel(),
V[e:-e, :e].ravel(), V[e:-e, -e:].ravel()])
return V[e:-e, e:-e].mean() / (border.mean() + 1e-6)
bz5, bz10, bz15 = bezel_ratio(11), bezel_ratio(22), bezel_ratio(34)
c = 34
corners = [V[:c, :c].mean(), V[:c, -c:].mean(), V[-c:, :c].mean(), V[-c:, -c:].mean()]
corner_mean = np.mean(corners)
corner_min = np.min(corners)
border_5 = np.concatenate([V[:11, :].ravel(), V[-11:, :].ravel(),
V[11:-11, :11].ravel(), V[11:-11, -11:].ravel()])
dark_border_frac = (border_5 < 0.1).mean()
top_bot = V[:112, :].mean() / (V[112:, :].mean() + 1e-6)
h3, w3 = 224 // 3, 224 // 3
region_std = np.std([V[i*h3:(i+1)*h3, j*w3:(j+1)*w3].mean()
for i in range(3) for j in range(3)])
return np.array([
bz5, bz10, bz15,
corner_mean, corner_min, dark_border_frac,
bri_std, region_std,
sat_std,
], dtype=np.float32)
# ──────────────────────────────────────────────────────────────────────────────
# MODEL (must match train.py build_model() exactly)
# ──────────────────────────────────────────────────────────────────────────────
def build_model():
return nn.Sequential(
nn.Linear(9, 32),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(32, 1),
)
_model = None
_norm_mu = None
_norm_std = None
def load_model():
global _model, _norm_mu, _norm_std
try:
checkpoint = torch.load(MODEL_PATH, map_location="cpu", weights_only=True)
except TypeError:
checkpoint = torch.load(MODEL_PATH, map_location="cpu")
_model = build_model()
_model.load_state_dict(checkpoint["model"])
_model.eval()
_norm_mu = checkpoint["norm_mean"]
_norm_std = checkpoint["norm_std"]
def calibrate_score(raw_score, image_path):
img = Image.open(image_path)
width, height = img.size
exif_count = len(img.getexif())
meta = np.array([
raw_score,
width,
height,
width / max(height, 1),
max(width, height),
min(width, height),
exif_count,
int((width, height) in COMMON_SCREEN_SIZES),
int(exif_count > 5),
], dtype=np.float32)
z = (meta - CAL_MEAN) / CAL_STD
logit = float(np.dot(z, CAL_WEIGHTS) + CAL_BIAS)
return float(1.0 / (1.0 + np.exp(-np.clip(logit, -30, 30))))
def to_jpg(image_path: str) -> str:
"""Convert any image format to jpg in-memory path. Returns original path if already jpg."""
if image_path.lower().endswith('.jpg'):
return image_path
import tempfile, os
img = Image.open(image_path).convert("RGB")
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
img.save(tmp.name, "JPEG", quality=95)
return tmp.name
def predict(image_path: str) -> float:
"""
Returns a float in [0, 1].
< 0.5 β†’ real photo
>= 0.5 β†’ screen recapture
Accepts any image format (jpg, jpeg, png, webp, heic, etc.)
"""
if _model is None:
load_model()
jpg_path = to_jpg(image_path)
feat = torch.tensor(extract_features(jpg_path)).unsqueeze(0)
feat_n = (feat - _norm_mu) / _norm_std
with torch.no_grad():
raw_score = torch.sigmoid(_model(feat_n).squeeze()).item()
# Clean up temp file if we created one
if jpg_path != image_path:
import os; os.unlink(jpg_path)
return raw_score
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python predict.py <image_path>")
sys.exit(1)
score = predict(sys.argv[1])
print(f"{score:.4f}")