Spaces:
Paused
Paused
feat: add predict_ensemble, conservative forensic fusion, and exif harvester
Browse files- api/model_server.py +187 -0
api/model_server.py
CHANGED
|
@@ -59,6 +59,63 @@ TURBO_LUT = np.array([
|
|
| 59 |
], dtype=np.uint8)
|
| 60 |
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
def check_photographic_domain(
|
| 63 |
image: Image.Image,
|
| 64 |
filename: Optional[str] = None,
|
|
@@ -252,6 +309,7 @@ class TamperingModelServer:
|
|
| 252 |
self,
|
| 253 |
image: Image.Image,
|
| 254 |
filename: Optional[str] = None,
|
|
|
|
| 255 |
) -> Dict[str, Any]:
|
| 256 |
start = time.time()
|
| 257 |
tensor = self._preprocess(image)
|
|
@@ -314,6 +372,12 @@ class TamperingModelServer:
|
|
| 314 |
blockiness = ood_info.get("compression_blockiness", 1.0)
|
| 315 |
warning = ood_info["warning"]
|
| 316 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
conf = round(probs[pred_idx].item(), 4)
|
| 318 |
if conf < 0.60:
|
| 319 |
entropy_msg = f"Low triage certainty ({conf * 100:.1f}%). Model exhibits high entropy across forensic classes."
|
|
@@ -335,5 +399,128 @@ class TamperingModelServer:
|
|
| 335 |
"domain_status": domain_status,
|
| 336 |
"is_social_media_compressed": is_sm_compressed,
|
| 337 |
"compression_blockiness": blockiness,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
"warning": warning,
|
| 339 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
], dtype=np.uint8)
|
| 60 |
|
| 61 |
|
| 62 |
+
def extract_exif_metadata(image: Image.Image) -> Dict[str, Any]:
|
| 63 |
+
"""
|
| 64 |
+
Extracts camera hardware metadata and detects AI generation software tags
|
| 65 |
+
embedded within EXIF, XMP, or PNG text segments.
|
| 66 |
+
"""
|
| 67 |
+
exif_summary = {}
|
| 68 |
+
generative_tags = []
|
| 69 |
+
has_exif = False
|
| 70 |
+
|
| 71 |
+
gen_keywords = [
|
| 72 |
+
"firefly", "stable diffusion", "midjourney", "dall-e", "dalle",
|
| 73 |
+
"comfyui", "automatic1111", "novelai", "invokeai", "photoshop",
|
| 74 |
+
"generative", "canva", "bing creator"
|
| 75 |
+
]
|
| 76 |
+
|
| 77 |
+
try:
|
| 78 |
+
from PIL import ExifTags
|
| 79 |
+
raw_exif = image.getexif()
|
| 80 |
+
if raw_exif:
|
| 81 |
+
for tag_id, val in raw_exif.items():
|
| 82 |
+
tag_name = ExifTags.TAGS.get(tag_id, str(tag_id))
|
| 83 |
+
val_str = str(val).strip()
|
| 84 |
+
if val_str and len(val_str) < 120:
|
| 85 |
+
exif_summary[tag_name] = val_str
|
| 86 |
+
has_exif = True
|
| 87 |
+
for kw in gen_keywords:
|
| 88 |
+
if kw in val_str.lower() and kw not in generative_tags:
|
| 89 |
+
generative_tags.append(kw)
|
| 90 |
+
except Exception:
|
| 91 |
+
pass
|
| 92 |
+
|
| 93 |
+
for k, v in image.info.items():
|
| 94 |
+
if isinstance(v, (str, bytes)):
|
| 95 |
+
v_str = str(v).strip()
|
| 96 |
+
if len(v_str) < 150:
|
| 97 |
+
exif_summary[f"info_{k}"] = v_str
|
| 98 |
+
for kw in gen_keywords:
|
| 99 |
+
if kw in v_str.lower() and kw not in generative_tags:
|
| 100 |
+
generative_tags.append(kw)
|
| 101 |
+
|
| 102 |
+
# Focus on standard photographic capture audit parameters
|
| 103 |
+
key_tags = [
|
| 104 |
+
"Make", "Model", "Software", "DateTimeOriginal", "LensModel",
|
| 105 |
+
"ExposureTime", "FNumber", "ISOSpeedRatings", "FocalLength"
|
| 106 |
+
]
|
| 107 |
+
filtered_exif = {k: exif_summary[k] for k in key_tags if k in exif_summary}
|
| 108 |
+
if not filtered_exif and exif_summary:
|
| 109 |
+
filtered_exif = {k: v for i, (k, v) in enumerate(exif_summary.items()) if i < 8}
|
| 110 |
+
|
| 111 |
+
return {
|
| 112 |
+
"has_exif": has_exif,
|
| 113 |
+
"exif_summary": filtered_exif if filtered_exif else None,
|
| 114 |
+
"generative_software_tag": ", ".join(generative_tags) if generative_tags else None,
|
| 115 |
+
"is_generative_exif": len(generative_tags) > 0,
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
def check_photographic_domain(
|
| 120 |
image: Image.Image,
|
| 121 |
filename: Optional[str] = None,
|
|
|
|
| 309 |
self,
|
| 310 |
image: Image.Image,
|
| 311 |
filename: Optional[str] = None,
|
| 312 |
+
sha256_hash: Optional[str] = None,
|
| 313 |
) -> Dict[str, Any]:
|
| 314 |
start = time.time()
|
| 315 |
tensor = self._preprocess(image)
|
|
|
|
| 372 |
blockiness = ood_info.get("compression_blockiness", 1.0)
|
| 373 |
warning = ood_info["warning"]
|
| 374 |
|
| 375 |
+
# EXIF Hardware & Software metadata audit
|
| 376 |
+
exif_res = extract_exif_metadata(image)
|
| 377 |
+
if exif_res["is_generative_exif"]:
|
| 378 |
+
gen_tag_msg = f"AI Generation Metadata Detected in EXIF ({exif_res['generative_software_tag']})"
|
| 379 |
+
warning = f"{warning} | {gen_tag_msg}" if warning else gen_tag_msg
|
| 380 |
+
|
| 381 |
conf = round(probs[pred_idx].item(), 4)
|
| 382 |
if conf < 0.60:
|
| 383 |
entropy_msg = f"Low triage certainty ({conf * 100:.1f}%). Model exhibits high entropy across forensic classes."
|
|
|
|
| 399 |
"domain_status": domain_status,
|
| 400 |
"is_social_media_compressed": is_sm_compressed,
|
| 401 |
"compression_blockiness": blockiness,
|
| 402 |
+
"consensus_status": "single_model",
|
| 403 |
+
"consensus_message": None,
|
| 404 |
+
"exif_summary": exif_res["exif_summary"],
|
| 405 |
+
"generative_software_tag": exif_res["generative_software_tag"],
|
| 406 |
+
"sha256_hash": sha256_hash,
|
| 407 |
+
"cached": False,
|
| 408 |
"warning": warning,
|
| 409 |
}
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def predict_ensemble(
|
| 413 |
+
server_base: TamperingModelServer,
|
| 414 |
+
server_large: TamperingModelServer,
|
| 415 |
+
image: Image.Image,
|
| 416 |
+
filename: Optional[str] = None,
|
| 417 |
+
sha256_hash: Optional[str] = None,
|
| 418 |
+
) -> Dict[str, Any]:
|
| 419 |
+
"""
|
| 420 |
+
SOTA Conservative Forensic Fusion Ensemble combining Patch-MFFT Base (3.28M)
|
| 421 |
+
and Patch-MFFT Large (13.94M). Applies tampering priority gating and element-wise
|
| 422 |
+
maximum likelihood localization mask fusion.
|
| 423 |
+
"""
|
| 424 |
+
start = time.time()
|
| 425 |
+
|
| 426 |
+
res_b = server_base.predict(image, filename=filename, sha256_hash=sha256_hash)
|
| 427 |
+
res_l = server_large.predict(image, filename=filename, sha256_hash=sha256_hash)
|
| 428 |
+
|
| 429 |
+
pb = res_b["class_probabilities"]
|
| 430 |
+
pl = res_l["class_probabilities"]
|
| 431 |
+
|
| 432 |
+
# Conservative Forensic Fusion (Tampering Priority Gating)
|
| 433 |
+
# If Base detected localized inpainting (p_spliced > 0.45), preserve localized tampering
|
| 434 |
+
if pb["spliced_tampered"] > 0.45:
|
| 435 |
+
prediction_label = "spliced_tampered"
|
| 436 |
+
conf = pb["spliced_tampered"]
|
| 437 |
+
# If Large or Base detected Full AI with high confidence, preserve generative evidence
|
| 438 |
+
elif pl["full_ai"] > 0.70 or pb["full_ai"] > 0.70:
|
| 439 |
+
prediction_label = "full_ai"
|
| 440 |
+
conf = max(pl["full_ai"], pb["full_ai"])
|
| 441 |
+
else:
|
| 442 |
+
ens_auth = 0.40 * pb["authentic"] + 0.60 * pl["authentic"]
|
| 443 |
+
ens_ai = 0.40 * pb["full_ai"] + 0.60 * pl["full_ai"]
|
| 444 |
+
ens_splice = 0.40 * pb["spliced_tampered"] + 0.60 * pl["spliced_tampered"]
|
| 445 |
+
candidates = [("authentic", ens_auth), ("full_ai", ens_ai), ("spliced_tampered", ens_splice)]
|
| 446 |
+
candidates.sort(key=lambda x: x[1], reverse=True)
|
| 447 |
+
prediction_label = candidates[0][0]
|
| 448 |
+
conf = candidates[0][1]
|
| 449 |
+
|
| 450 |
+
# Consensus Status
|
| 451 |
+
if res_b["prediction"] == res_l["prediction"]:
|
| 452 |
+
consensus_status = "verified_consensus"
|
| 453 |
+
consensus_message = f"Multi-Model Consensus: Both Base ({res_b['confidence']*100:.1f}%) and Large ({res_l['confidence']*100:.1f}%) agree unanimously on '{prediction_label}'."
|
| 454 |
+
else:
|
| 455 |
+
consensus_status = "disputed"
|
| 456 |
+
consensus_message = (
|
| 457 |
+
f"Disputed Forensic Case: Base ({res_b['prediction']}, {res_b['confidence']*100:.1f}%) "
|
| 458 |
+
f"vs Large ({res_l['prediction']}, {res_l['confidence']*100:.1f}%). "
|
| 459 |
+
f"Conservative forensic fusion prioritized the higher tampering indicator."
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
# Element-Wise Maximum Likelihood Heatmap Fusion
|
| 463 |
+
t_b = server_base._preprocess(image)
|
| 464 |
+
t_l = server_large._preprocess(image)
|
| 465 |
+
with torch.no_grad():
|
| 466 |
+
_, mask_b = server_base.model(t_b, return_mask=True)
|
| 467 |
+
_, mask_l = server_large.model(t_l, return_mask=True)
|
| 468 |
+
m_b = torch.sigmoid(mask_b)[0, 0].cpu().numpy()
|
| 469 |
+
m_l = torch.sigmoid(mask_l)[0, 0].cpu().numpy()
|
| 470 |
+
fused_mask = np.maximum(m_b, m_l)
|
| 471 |
+
|
| 472 |
+
peak_score = round(float(fused_mask.max()), 4)
|
| 473 |
+
coverage_ratio = round(float(np.mean(fused_mask > 0.50)), 4)
|
| 474 |
+
|
| 475 |
+
lut_idx = np.clip(fused_mask * 255.0, 0, 255).astype(np.int32)
|
| 476 |
+
heatmap_rgb = TURBO_LUT[lut_idx]
|
| 477 |
+
mask_pil = Image.fromarray(heatmap_rgb, mode="RGB")
|
| 478 |
+
buf = io.BytesIO()
|
| 479 |
+
mask_pil.save(buf, format="PNG")
|
| 480 |
+
mask_b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
|
| 481 |
+
|
| 482 |
+
overlay_rgba = np.zeros((server_base.img_size, server_base.img_size, 4), dtype=np.uint8)
|
| 483 |
+
overlay_rgba[:, :, :3] = heatmap_rgb
|
| 484 |
+
alpha_map = np.clip((fused_mask - 0.12) / 0.25 * 210.0, 0, 210).astype(np.uint8)
|
| 485 |
+
overlay_rgba[:, :, 3] = alpha_map
|
| 486 |
+
overlay_pil = Image.fromarray(overlay_rgba, mode="RGBA")
|
| 487 |
+
buf_o = io.BytesIO()
|
| 488 |
+
overlay_pil.save(buf_o, format="PNG")
|
| 489 |
+
overlay_b64 = base64.b64encode(buf_o.getvalue()).decode("utf-8")
|
| 490 |
+
|
| 491 |
+
class_probs = {
|
| 492 |
+
"authentic": round(0.40 * pb["authentic"] + 0.60 * pl["authentic"], 4),
|
| 493 |
+
"full_ai": round(0.40 * pb["full_ai"] + 0.60 * pl["full_ai"], 4),
|
| 494 |
+
"spliced_tampered": round(0.40 * pb["spliced_tampered"] + 0.60 * pl["spliced_tampered"], 4),
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
warning = res_b.get("warning") or res_l.get("warning")
|
| 498 |
+
if consensus_status == "disputed":
|
| 499 |
+
warning = f"{warning} | {consensus_message}" if warning else consensus_message
|
| 500 |
+
|
| 501 |
+
elapsed_ms = (time.time() - start) * 1000.0
|
| 502 |
+
|
| 503 |
+
return {
|
| 504 |
+
"prediction": prediction_label,
|
| 505 |
+
"confidence": round(float(conf), 4),
|
| 506 |
+
"tampering_detected": prediction_label in ["full_ai", "spliced_tampered"],
|
| 507 |
+
"class_probabilities": class_probs,
|
| 508 |
+
"tampering_heatmap_b64": mask_b64,
|
| 509 |
+
"tampering_overlay_b64": overlay_b64,
|
| 510 |
+
"tampering_peak_score": peak_score,
|
| 511 |
+
"tampering_coverage_ratio": coverage_ratio,
|
| 512 |
+
"frequency_band_contributions": res_l["frequency_band_contributions"],
|
| 513 |
+
"processing_time_ms": round(elapsed_ms, 2),
|
| 514 |
+
"model_variant": "ensemble",
|
| 515 |
+
"is_non_photographic_graphic": res_b["is_non_photographic_graphic"],
|
| 516 |
+
"domain_status": res_b["domain_status"],
|
| 517 |
+
"is_social_media_compressed": res_b["is_social_media_compressed"],
|
| 518 |
+
"compression_blockiness": res_b["compression_blockiness"],
|
| 519 |
+
"consensus_status": consensus_status,
|
| 520 |
+
"consensus_message": consensus_message,
|
| 521 |
+
"exif_summary": res_b.get("exif_summary"),
|
| 522 |
+
"generative_software_tag": res_b.get("generative_software_tag"),
|
| 523 |
+
"sha256_hash": sha256_hash,
|
| 524 |
+
"cached": False,
|
| 525 |
+
"warning": warning,
|
| 526 |
+
}
|