truscan / route /analysis_engine.py
Manar55's picture
Deploy a5180b1
82d7501 verified
Raw History Blame Contribute Delete
52.1 kB
"""
Local digital forensics engine - runs 100% offline.
Supports: image analysis (ELA + EXIF + AI-pattern detection)
and video analysis (frame consistency + face-region checks).
Each "reason" is returned as a structured dict:
{"key": "<translation key>", "params": {...}, "en": "<English fallback text>"}
so the frontend can render it in any supported language while still
having a safe English fallback if a key isn't recognized.
"""
import io
import os
import json
import time
import logging
from PIL import Image, ExifTags
import numpy as np
import cv2
import piexif
import ai_detector
import deepfake_face_detector
import face_detector
import efficientnetb3_detector
import sdxl_face_detector
import noise_residual_detector
import clone_detection_module
import generation_metadata_detector
import pixel_forensics_detector
import community_forensics_detector
import c2pa_detector
import steganographia_detector
import nonescape_bridge
import insightface_detector
import video_metadata_detector
import blink_detector
import audio_extractor
import voice_deepfake_detector
import lipsync_detector
import lighting_balance_detector
# ============================================================
# External settings (config.json, project root β€” next to run.bat /
# the packaged .exe). Lets weights be tuned by editing a plain text
# file and restarting the app β€” no code change or rebuild needed.
# Falls back to these hardcoded defaults (the current tuned values) if
# config.json is missing, unreadable, or missing a specific key, so a
# broken/incomplete config file can never crash the app.
# ============================================================
_DEFAULT_IMAGE_WEIGHTS = {
"exif": 0.10, "model": 0.28, "face_df": 0.10, "effnb3": 0.40,
"noise_residual": 0.40, "clone_detection": 0.40, "nonescape": 0.50,
}
_DEFAULT_VIDEO_WEIGHTS = {
"heuristic": 0.25, "model": 0.25, "face_df": 0.20, "rppg": 0.30, "meta": 0.15, "blink": 0.20,
"nonescape": 0.40, "noise": 0.20, "pixel": 0.15, "voice": 0.25, "lipsync": 0.20,
}
_CONFIG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json")
def _load_weights(config_key, defaults):
try:
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
cfg = json.load(f)
loaded = cfg.get(config_key, {})
merged = dict(defaults)
for k, v in loaded.items():
if k in merged and isinstance(v, (int, float)):
merged[k] = float(v)
return merged
except Exception as e:
print(f"[analysis_engine] Could not load {config_key} from config.json ({e}) β€” using built-in defaults.")
return dict(defaults)
IMAGE_WEIGHTS = _load_weights("image_weights", _DEFAULT_IMAGE_WEIGHTS)
VIDEO_WEIGHTS = _load_weights("video_weights", _DEFAULT_VIDEO_WEIGHTS)
def r(key, en, **params):
"""Helper to build a structured reason entry."""
return {"key": key, "params": params, "en": en}
def _safe_remove(path, retries=5, delay=0.3):
"""
Deletes a temp file, retrying briefly on Windows file-lock errors
(OneDrive syncing the file, antivirus scanning it, etc. can hold a
transient lock right after we finish writing/reading it β€” a real,
observed cause of PermissionError here, not a bug in our own code).
Silently gives up after the retries; a leftover temp file isn't
fatal, just noise in the temp folder.
"""
for attempt in range(retries):
try:
if os.path.exists(path):
os.remove(path)
return
except PermissionError:
if attempt == retries - 1:
print(f"[analysis_engine] Could not remove temp file after {retries} tries (locked by another process): {path}")
return
time.sleep(delay)
# ============================================================
# Image analysis
# ============================================================
def check_exif(pil_image):
reasons = []
suspicion = 0
matched_software = None
exif_raw = pil_image._getexif() if hasattr(pil_image, "_getexif") else None
if not exif_raw:
suspicion += 25
reasons.append(r("exif_none",
"No EXIF data at all β€” common in AI-generated images, screenshots, or images exported from editing tools that strip metadata"))
return suspicion, reasons, matched_software
exif = {ExifTags.TAGS.get(k, k): v for k, v in exif_raw.items()}
has_make = "Make" in exif or "Model" in exif
has_datetime = "DateTime" in exif or "DateTimeOriginal" in exif
has_software = "Software" in exif
if not has_make:
suspicion += 15
reasons.append(r("exif_no_make",
"No camera/device info (Make/Model) β€” unusual for an original photo"))
if has_software:
software_val = str(exif.get("Software", ""))
editing_tools = ["photoshop", "gimp", "lightroom", "midjourney", "dall-e", "stable diffusion", "canva"]
matched = next((t for t in editing_tools if t in software_val.lower()), None)
if matched:
matched_software = software_val
suspicion += 30
reasons.append(r("exif_software_match",
f"Software field explicitly references an editing/generation tool: '{software_val}'",
value=software_val))
else:
reasons.append(r("exif_software_present",
f"Software field present: '{software_val}'",
value=software_val))
if has_make and has_datetime:
reasons.append(r("exif_complete",
"EXIF data is relatively complete (device + timestamp) β€” consistent with an original camera photo"))
gps_present = False
try:
exif_dict = piexif.load(pil_image.info.get("exif", b""))
gps_data = exif_dict.get("GPS", {})
gps_present = len(gps_data) > 0
except Exception:
pass
if gps_present:
suspicion = max(suspicion - 15, 0)
reasons.append(r("exif_gps_present",
"GPS location data found in EXIF β€” a strong signal of an original, unedited camera/phone photo (GPS tags are almost always stripped by editing or AI-generation tools)"))
else:
reasons.append(r("exif_gps_absent",
"No GPS data found β€” inconclusive on its own (many legitimate photos have location services disabled)"))
return min(suspicion, 100), reasons, matched_software
def check_ai_model(pil_image):
"""
Runs the trained ensemble AI-image detector (aidetector, with automatic
fallback to a simpler backend if the full "ultra" ensemble can't fully
download). Returns (score, reasons, available) β€” available=False means
every backend in the fallback chain failed to load, so callers should
skip this category entirely rather than showing a misleading 0%.
"""
ai_prob = ai_detector.classify_image(pil_image)
if ai_prob is None:
return 0, [], False
backend = ai_detector.get_active_backend() or "unknown"
reasons = [r("ai_model_score",
f"Trained model ({backend} backend) estimates a {ai_prob:.0f}% probability this image is AI-generated",
value=f"{ai_prob:.0f}", backend=backend)]
return ai_prob, reasons, True
def check_efficientnetb3(pil_image):
"""
Fourth trained model: EfficientNet-B3, fine-tuned specifically for
deepfake detection on a 399k-image dataset. Ships with real, validated
test-set metrics embedded in its checkpoint (93.7% accuracy, 0.988
ROC-AUC) β€” currently our strongest single model. Returns (score, reasons, available).
"""
prob = efficientnetb3_detector.classify_image(pil_image)
if prob is None:
return 0, [], False
acc = efficientnetb3_detector.get_reported_accuracy()
acc_note = f", model's own validated test accuracy: {acc*100:.1f}%" if acc else ""
reasons = [r("effnb3_score",
f"EfficientNet-B3 deepfake detector estimates a {prob:.0f}% probability this image is fake{acc_note}",
value=f"{prob:.0f}", acc=f"{acc*100:.1f}" if acc else "")]
return prob, reasons, True
def check_noise_residual(pil_image):
"""
PRNU-inspired noise-pattern heuristic (2026-08). Tested standalone at
63.0% balanced accuracy on 200 labeled samples β€” the strongest single
signal in this project so far. No model, no download, always
available. See noise_residual_detector.py for the method.
"""
suspicion = noise_residual_detector.classify_image(pil_image)
if suspicion is None:
return 0, [], False
reasons = [r("noise_residual_score",
f"Noise-pattern analysis estimates a {suspicion:.0f}% likelihood of an unnatural (AI-smoothed or synthetic) noise signature",
value=f"{suspicion:.0f}")]
return suspicion, reasons, True
def check_clone_detection(pil_image):
"""
INFORMATIONAL ONLY (2026-08) β€” not standalone-tested first (unlike
noise_residual, which was validated via test_noise_residual.py before
integration), so not in the weighted blend yet. Detects copy-pasted
regions within the same image (classic splicing signature) via ORB
keypoint self-matching + displacement clustering. Test via
bulk_calibrate.py + recalibrate.py before adding a weight.
"""
suspicion = clone_detection_module.classify_image(pil_image)
if suspicion is None:
return 0, [], False
reasons = [r("clone_detection_score",
f"Copy-move analysis estimates a {suspicion:.0f}% likelihood of a duplicated/pasted region within the image",
value=f"{suspicion:.0f}")]
return suspicion, reasons, True
def check_insightface(pil_image):
"""
AUXILIARY/INFORMATIONAL ONLY (2026-08) β€” per the original plan's
explicit note, InsightFace is a supporting feature, not a fake/real
decider. Landmark symmetry + detection confidence heuristic, not a
validated classifier. Not standalone-tested β€” test via
bulk_calibrate.py + recalibrate.py before considering any weight.
"""
result = insightface_detector.analyze(pil_image)
if result is None or not result.get("face_found"):
return None, [], False
suspicion = result["naturalness_suspicion"]
reasons = [r("insightface_naturalness",
f"Face landmark/detection analysis (InsightFace) estimates a {suspicion:.0f}% 'unnatural face' suspicion "
f"(detection confidence: {result['det_score']:.2f}) β€” a weak, indirect heuristic, not a validated classifier",
value=f"{suspicion:.0f}")]
return suspicion, reasons, True
def check_nonescape(pil_image):
"""
ACTIVE BLEND SIGNAL (2026-08) β€” DINOv2-large + EfficientNetV2-L
cross-attention model (aediliclabs/nonescape, Apache 2.0). Runs via
subprocess in an isolated venv (see nonescape_bridge.py) since it
requires transformers==4.53.2, conflicting with this project's
4.46.3. Promoted to the active blend at a MODERATE weight based on
consistent AUC=0.74-0.78 across two independent tests (n=299
standalone, n=203 in a merged 3-signal test) β€” stronger evidence
than clone_detection ever had, but the sample is still smaller than
effnb3/community_forensics' full history, so weighted cautiously
rather than aggressively until more data accumulates.
"""
suspicion = nonescape_bridge.classify_image(pil_image)
if suspicion is None:
return 0, [], False
reasons = [r("nonescape_score",
f"Nonescape (DINOv2+EfficientNet, Apache 2.0) estimates a {suspicion:.0f}% probability this image is AI-generated",
value=f"{suspicion:.0f}")]
return suspicion, reasons, True
def check_steganographia(pil_image):
"""
INFORMATIONAL/SECONDARY ONLY (2026-08) β€” ViT-Base trained on the
Defactify dataset (SD2.1/XL/3, DALL-E 3, Midjourney v6). Deliberately
kept secondary per its own model card's disclosed limitations: no
guaranteed generalization to Flux/future generators, ~86% recall on
AI images (~14% miss rate), 224x224 downscaling loses detail. Not
standalone-tested on this project's data yet β€” test via
bulk_calibrate.py + recalibrate.py before considering any weight.
"""
suspicion = steganographia_detector.classify_image(pil_image)
if suspicion is None:
return 0, [], False
reasons = [r("steganographia_score",
f"SteganographIA (Defactify-trained ViT) estimates a {suspicion:.0f}% probability this image is AI-generated "
f"β€” secondary signal only, not guaranteed to generalize beyond its training generators",
value=f"{suspicion:.0f}")]
return suspicion, reasons, True
def check_c2pa(image_bytes):
"""
DIRECT EVIDENCE, not a model guess. Reads a C2PA (Content Credentials)
manifest if present. Presence proves provenance claims (who/what made
it, recorded edit actions); absence proves NOTHING β€” most images
don't have this data. See c2pa_detector.py's docstring.
"""
result = c2pa_detector.check(image_bytes, mime_type="image/jpeg")
if not result.get("found"):
return None, [], False
reasons = []
if result["valid"] is False:
reasons.append(r("c2pa_invalid",
f"C2PA manifest found but signature validation FAILED β€” the file may have been altered after signing ({result['raw_summary']})",
summary=result["raw_summary"]))
else:
reasons.append(r("c2pa_found",
f"C2PA content credentials found: {result['raw_summary']}",
summary=result["raw_summary"]))
return result, reasons, True
def check_community_forensics(pil_image):
"""
INFORMATIONAL ONLY (2026-08) β€” CVPR 2025 model trained on 2.7M
images from 4803 different generators, designed for broad
generalization across many AI tools rather than one specific
generator family. Not standalone-tested yet β€” test via
bulk_calibrate.py + recalibrate.py before adding a weight, same
process as every other signal in this project.
"""
suspicion = community_forensics_detector.classify_image(pil_image)
if suspicion is None:
return 0, [], False
reasons = [r("community_forensics_score",
f"Community Forensics (trained on 4803 different AI generators) estimates a {suspicion:.0f}% probability this image is AI-generated",
value=f"{suspicion:.0f}")]
return suspicion, reasons, True
def check_pixel_forensics(pil_image):
"""
INFORMATIONAL ONLY (2026-08) β€” classic OpenCV pixel forensics
(currently just resize/resampling trace detection; double-JPEG and
block-boundary sub-checks are disabled, see pixel_forensics_detector.py
for why). Not standalone-tested at scale β€” same lesson as
clone_detection_module.py (looked good at n=94, dropped to near-
chance at n=2154). Test via bulk_calibrate.py + recalibrate.py
before trusting or adding a weight.
"""
result = pixel_forensics_detector.classify_image(pil_image)
if result is None:
return 0, [], False
score = result["combined_score"]
reasons = [r("pixel_forensics_resize",
f"Resize/resampling trace analysis estimates a {score:.0f}% likelihood of interpolation artifacts (image was resized/rescaled)",
value=f"{score:.0f}")]
return score, reasons, True
def check_sdxl_face(pil_image):
"""
INFORMATIONAL ONLY (2026-08) β€” not yet in the weighted blend. A ViT
trained specifically on SDXL/diffusion-generated faces (unlike the
removed stylegan_hybrid_detector, which was StyleGAN2-specific and
anti-correlated on this app's mostly-diffusion data). Test standalone
accuracy via recalibrate.py before adding a weight for this.
Returns (score, reasons, available).
"""
arr_rgb = np.array(pil_image.convert("RGB"))
arr_bgr = cv2.cvtColor(arr_rgb, cv2.COLOR_RGB2BGR)
faces = face_detector.detect_faces(arr_bgr)
crop = pil_image
face_found = False
if len(faces) > 0:
x, y, w, h = faces[0]
pad = int(0.15 * max(w, h))
x0, y0 = max(0, x - pad), max(0, y - pad)
x1, y1 = min(arr_rgb.shape[1], x + w + pad), min(arr_rgb.shape[0], y + h + pad)
crop = pil_image.crop((x0, y0, x1, y1))
face_found = True
suspicion = sdxl_face_detector.classify_image(crop)
if suspicion is None:
return 0, [], False
note = "" if face_found else " (no face detected β€” ran on the whole image, less reliable)"
reasons = [r("sdxl_face_score",
f"SDXL/diffusion-focused face model estimates a {suspicion:.0f}% probability this face is AI-generated{note}",
value=f"{suspicion:.0f}")]
return suspicion, reasons, True
def check_face_deepfake(pil_image):
"""
Second, complementary trained model specialized for FACE deepfakes
(face-swap/reenactment), as opposed to check_ai_model()'s whole-image
AI-generation focus. Crops the largest detected face (falls back to
the full image if no face is found) and runs it through a dedicated
ViT deepfake-face classifier. Returns (score, reasons, available).
"""
arr_rgb = np.array(pil_image.convert("RGB"))
arr_bgr = cv2.cvtColor(arr_rgb, cv2.COLOR_RGB2BGR)
faces = face_detector.detect_faces(arr_bgr)
crop = pil_image
face_found = False
if len(faces) > 0:
# Use the largest detected face (already sorted), with a small margin
x, y, w, h = faces[0]
pad = int(0.15 * max(w, h))
x0, y0 = max(0, x - pad), max(0, y - pad)
x1, y1 = min(arr_rgb.shape[1], x + w + pad), min(arr_rgb.shape[0], y + h + pad)
crop = pil_image.crop((x0, y0, x1, y1))
face_found = True
prob = deepfake_face_detector.classify_face(crop)
if prob is None:
return 0, [], False
if face_found:
reasons = [r("face_deepfake_score",
f"Face-specialized model estimates a {prob:.0f}% probability this face is a deepfake/face-swap",
value=f"{prob:.0f}")]
else:
reasons = [r("face_deepfake_no_face",
f"No clear face detected β€” ran the face-deepfake model on the whole image instead (estimate: {prob:.0f}%, less reliable without a cropped face)",
value=f"{prob:.0f}")]
return prob, reasons, True
def analyze_image_bytes(image_bytes):
pil_image = Image.open(io.BytesIO(image_bytes))
exif_score, exif_reasons, exif_matched_software = check_exif(pil_image)
# DIRECT EVIDENCE check β€” some AI tools embed generation parameters
# straight into the file (see generation_metadata_detector.py's
# docstring). When present, this is proof, not a guess.
gen_meta = generation_metadata_detector.check(pil_image, raw_bytes=image_bytes)
model_score, model_reasons, model_available = check_ai_model(pil_image)
face_df_score, face_df_reasons, face_df_available = check_face_deepfake(pil_image)
effnb3_score, effnb3_reasons, effnb3_available = check_efficientnetb3(pil_image)
# INFORMATIONAL ONLY β€” not in base_weights below yet. Test standalone
# accuracy tomorrow via recalibrate.py before adding a weight.
sdxl_score, sdxl_reasons, sdxl_available = check_sdxl_face(pil_image)
noise_score, noise_reasons, noise_available = check_noise_residual(pil_image)
# INFORMATIONAL ONLY β€” not in base_weights below yet. Test via
# bulk_calibrate.py + recalibrate.py before adding a weight.
clone_score, clone_reasons, clone_available = check_clone_detection(pil_image)
pixel_score, pixel_reasons, pixel_available = check_pixel_forensics(pil_image)
comm_score, comm_reasons, comm_available = check_community_forensics(pil_image)
c2pa_result, c2pa_reasons, c2pa_available = check_c2pa(image_bytes)
steg_score, steg_reasons, steg_available = check_steganographia(pil_image)
nonescape_score, nonescape_reasons, nonescape_available = check_nonescape(pil_image)
insightface_score, insightface_reasons, insightface_available = check_insightface(pil_image)
# INFORMATIONAL ONLY β€” see lighting_balance_detector.py's docstring:
# a similar region-consistency approach was tried and removed before
# (AUC=0.501). Descriptive only until tested via recalibrate.py.
light_score, light_reason_raw, light_available = lighting_balance_detector.analyze_lighting_balance(pil_image)
light_reasons = [r(light_reason_raw["key"], light_reason_raw["text"], **light_reason_raw["params"])] if light_reason_raw else []
# Weights recalibrated 2026-08 from a 10472-image labeled test (real
# AI-generated images + CASIA-style splicing + subtle "edited"
# AI-restoration images). ELA, Dual-Stream, Copy-Move DCT, and the
# Noise/FFT heuristic ("ai") were all REMOVED entirely from the
# pipeline after measuring at-or-below-random accuracy specifically on
# the "edited" (subtle AI enhancement) category across multiple
# independent test sets β€” the Noise/FFT check in particular measured
# 0% on both "fake" and "edited" across two separate datasets (its
# max possible suspicion score was capped at 45/100 by design, so it
# could never even flag an obvious fake with confidence). Two
# pixel-level segmentation models (Unet++ and DINOv2) were also tried
# and REMOVED after measuring ~5% accuracy on "edited"/splicing across
# three independent tests β€” they failed even on the exact task they
# were built for. EfficientNet-B3 and the general AI Detector remain
# the strongest validated signals (~63-64% each, and ~80-99% on
# fake/edited specifically, though with a real-image false-positive
# tendency); EXIF metadata (often stripped/regenerated by AI tools)
# is a genuinely useful signal too.
# Weights recalibrated 2026-08 from an 11572-image labeled test (the
# largest so far) via recalibrate.py's weight search. This is a
# meaningful jump: the previous hand-picked weights scored only 55.2%
# on this log; this combination scored 69.7%. Interesting finding:
# EfficientNet-B3 is individually the strongest signal (63.3%
# standalone) and got the highest weight as expected, but EXIF (only
# 55.2% standalone, weaker than "model"'s 61.2%) still got a bigger
# weight than "model" β€” the search found EXIF+EfficientNet-B3 combine
# better together than "model" does, even though "model" alone beats
# EXIF alone. Re-run recalibrate.py periodically as more calibration
# data accumulates to keep this current.
# Weights recalibrated 2026-08 from 2347 samples AFTER cleaning out
# the contaminated real_data (wikiart/ghibli-art/processed_ images
# mislabeled as "real") and replacing with verified new-data. First
# trustworthy recalibration in this project's history β€” everything
# before this used contaminated "real" samples. Balanced accuracy
# 55.6% (still modest β€” face_df/effnb3 remain the strongest signals,
# exif still contributes ~nothing, model is weak on its own too).
# "noise_residual" added 2026-08 β€” strongest standalone signal tested
# (63.0% balanced accuracy vs. effnb3's 57.4%), but this in-app weight
# is a provisional guess (0.20, taken proportionally from the others).
# Re-run recalibrate.py after collecting more data with this category
# present and let the search find its real weight.
# Weights updated 2026-08 from 3143-sample recalibration.
# clone_detection PROMOTED to the active blend (2026-08): validated
# standalone accuracy grew 60.6% (n=35) -> 62.1% (n=94) as more data
# came in β€” confirms it's a real signal, not sampling noise, so it's
# no longer informational-only.
# nonescape added 2026-08 at weight 0.40 (raised from 0.35) β€” see
# check_nonescape()'s docstring for the evidence behind promoting
# it to the active blend; AUC 0.74-0.78 is the strongest tested
# standalone evidence of any signal in this project.
base_weights = IMAGE_WEIGHTS
available_scores = {"exif": exif_score}
if model_available:
available_scores["model"] = model_score
if face_df_available:
available_scores["face_df"] = face_df_score
if effnb3_available:
available_scores["effnb3"] = effnb3_score
if noise_available:
available_scores["noise_residual"] = noise_score
if clone_available:
available_scores["clone_detection"] = clone_score
if nonescape_available:
available_scores["nonescape"] = nonescape_score
weight_sum = sum(base_weights[k] for k in available_scores)
final_score = sum(available_scores[k] * base_weights[k] for k in available_scores) / weight_sum
# Confidence floor: if any SINGLE trained model is highly confident this
# is fake, don't let the weighted average fully dilute that down just
# because other signals disagree β€” a strong individual red flag from a
# validated model shouldn't be washed out to "likely real". This does
# NOT override the blend when models agree it's real; it only raises
# the floor when at least one strong "fake" signal exists.
model_keys = ["model", "face_df", "effnb3", "noise_residual", "nonescape"]
trained_model_scores = [available_scores[k] for k in model_keys if k in available_scores]
if trained_model_scores:
strongest_fake_signal = max(trained_model_scores)
if strongest_fake_signal >= 65:
final_score = max(final_score, strongest_fake_signal * 0.6)
authenticity = 100 - final_score
# Thresholds shifted down 2026-08 alongside the weight recalibration
# above. recalibrate.py's search found threshold=20 as the accuracy-
# optimal SINGLE binary cutoff (real vs. fake-or-edited) for the new
# weights β€” that's closest to what "medium" (any suspicion) means
# here, so medium moved 40->20. "high" wasn't independently searched
# (recalibrate.py only tests one binary boundary, not this app's
# three-tier split) β€” kept the old medium/high gap (20 points) as a
# reasonable estimate: 60->40. Re-run recalibrate.py after more
# labeled data accumulates and sanity-check this gap specifically if
# you get a chance β€” it's an extrapolation, not a directly tested value.
# Thresholds updated 2026-08 alongside the weight recalibration above
# (first trustworthy one β€” see base_weights comment). recalibrate.py
# found threshold=50 as the balanced-accuracy-optimal binary cutoff on
# cleaned data β€” moved medium up from 20->50. "high" kept the same
# 20-point gap as before (medium+20): 40->70. Not independently
# searched β€” same caveat as last time, re-verify once more real data
# accumulates.
if final_score >= 70:
verdict = "This image is likely edited or AI-generated"
verdict_level = "high"
verdict_key = "image_high"
elif final_score >= 50:
verdict = "Moderate signals of possible editing β€” inconclusive"
verdict_level = "medium"
verdict_key = "image_medium"
else:
verdict = "This image is likely authentic / unedited"
verdict_level = "low"
verdict_key = "image_low"
# Sharpen the verdict wording using the face/background consistency
# check (2026-08): a big mismatch between the face and the rest of
# the image means the manipulation looks LOCALIZED (e.g. a swapped/
# retouched face on an otherwise-real photo) rather than the whole
# frame being AI-generated β€” say "edited" specifically instead of
# the generic "edited or AI-generated". Conversely, a suspicious
# score with a CONSISTENT face/background (low mismatch) points
# toward full-image generation instead.
#
# Priority order (2026-08 revision):
# 0. C2PA invalid signature β€” a FAILED cryptographic signature check
# is the strongest possible tamper evidence (the file changed
# after signing), so it outranks even gen_meta. A VALID C2PA
# signature naming an AI generator also confirms generation.
# 1. Embedded generation metadata (gen_meta) β€” DEFINITIVE, overrides
# everything else when present, since it's direct evidence from
# the file rather than any model's estimate.
#
# 2026-08 NOTE: the "localized edit vs fully generated" wording tier
# that used to sit here (based on region_consistency_detector's
# face/background noise mismatch) was REMOVED after
# recalibrate_v7.py showed region_consistency scores AUC=0.501 on
# 698 samples β€” statistically indistinguishable from a coin flip.
# That verdict-sharpening logic was built on noise, not signal β€”
# region_consistency_detector.py has been deleted from the project.
if c2pa_available and c2pa_result["valid"] is False:
final_score = max(final_score, 90)
verdict = f"This image has a C2PA signature that FAILED validation β€” the file was altered after being signed ({c2pa_result['raw_summary']})"
verdict_level = "high"
verdict_key = "image_c2pa_invalid"
elif c2pa_available and c2pa_result["valid"] and any(
term in (c2pa_result["generator"] or "").lower()
for term in ("dall", "chatgpt", "midjourney", "stable diffusion", "firefly", "gemini", "imagen")
):
final_score = max(final_score, 95)
verdict = f"This image has a valid C2PA signature naming {c2pa_result['generator']} as the generator β€” confirmed AI-generated"
verdict_level = "high"
verdict_key = "image_c2pa_confirmed_generated"
elif gen_meta["found"]:
final_score = max(final_score, 95)
verdict = f"This image contains embedded generation metadata identifying it as made with {gen_meta['tool']} β€” confirmed AI-generated, not a guess"
verdict_level = "high"
verdict_key = "image_confirmed_generated"
categories = [
{"key": "cat_exif", "name": "EXIF Metadata Inspection", "score": round(exif_score), "reasons": exif_reasons},
]
if model_available:
backend = ai_detector.get_active_backend() or "model"
categories.append({"key": "cat_ai_model", "name": f"AI Detector β€” {backend} (CLIP/HF ensemble)", "score": round(model_score), "reasons": model_reasons})
if face_df_available:
categories.append({"key": "cat_face_deepfake", "name": "Face Deepfake Detector (ViT)", "score": round(face_df_score), "reasons": face_df_reasons})
if effnb3_available:
categories.append({"key": "cat_effnb3", "name": "EfficientNet-B3 Deepfake Detector", "score": round(effnb3_score), "reasons": effnb3_reasons})
if sdxl_available:
categories.append({"key": "cat_sdxl_face", "name": "SDXL/Diffusion Face Detector (ViT)", "score": round(sdxl_score), "reasons": sdxl_reasons})
if noise_available:
categories.append({"key": "cat_noise_residual", "name": "Noise Residual Analysis", "score": round(noise_score), "reasons": noise_reasons})
if clone_available:
categories.append({"key": "cat_clone_detection", "name": "Clone/Copy-Move Detection", "score": round(clone_score), "reasons": clone_reasons})
if pixel_available:
categories.append({"key": "cat_pixel_forensics", "name": "Pixel Forensics (Resize Trace)", "score": round(pixel_score), "reasons": pixel_reasons})
if comm_available:
categories.append({"key": "cat_community_forensics", "name": "Community Forensics (4803 generators)", "score": round(comm_score), "reasons": comm_reasons})
if c2pa_available:
c2pa_display_score = 100 if c2pa_result["valid"] is False else 0
categories.append({"key": "cat_c2pa", "name": "C2PA Content Credentials", "score": c2pa_display_score, "reasons": c2pa_reasons})
if steg_available:
categories.append({"key": "cat_steganographia", "name": "SteganographIA (Defactify ViT)", "score": round(steg_score), "reasons": steg_reasons})
if nonescape_available:
categories.append({"key": "cat_nonescape", "name": "Nonescape (DINOv2+EfficientNet)", "score": round(nonescape_score), "reasons": nonescape_reasons})
if insightface_available:
categories.append({"key": "cat_insightface", "name": "Face Naturalness (InsightFace)", "score": round(insightface_score), "reasons": insightface_reasons})
if light_available:
categories.append({"key": "cat_lighting_balance", "name": "Lighting Balance (informational)", "score": round(light_score), "reasons": light_reasons})
if gen_meta["found"]:
categories.append({
"key": "cat_generation_metadata", "name": "Embedded Generation Metadata",
"score": 100,
"reasons": [r("gen_metadata_found",
f"Found embedded generation parameters identifying this as made with: {gen_meta['tool']} β€” "
f"this is direct evidence from the file itself, not a model guess",
tool=gen_meta["tool"])],
})
return {
"type": "image",
"suspicion_score": round(final_score),
"authenticity_score": round(authenticity),
"verdict": verdict,
"verdict_key": verdict_key,
"verdict_level": verdict_level,
"categories": categories,
}
# ============================================================
# Video analysis
# ============================================================
def check_rppg(tmp_path):
"""
Runs the vendored BioVerify rPPG engine: looks for a physiologically
plausible heartbeat signal in facial skin color micro-variations.
Synthetic/deepfake video generally lacks a real, coherent pulse.
Returns (score 0-100 = suspicion, reasons, available).
"""
try:
from bioverify_engine import analyze_video as _rppg_analyze, Config as _RppgConfig, Verdict as _RppgVerdict
except Exception:
return 0, [], False
# bioverify_engine's own loggers reset themselves to INFO on import;
# silence them here (after import) so our server console stays clean.
for _name in ("bioverify_engine.ingest", "bioverify_engine.face", "bioverify_engine.roi",
"bioverify_engine.stabilization", "bioverify_engine.rppg",
"bioverify_engine.quality", "bioverify_engine.features", "bioverify_engine.scoring"):
logging.getLogger(_name).setLevel(logging.ERROR)
try:
result = _rppg_analyze(tmp_path, _RppgConfig())
except Exception:
return 0, [], False
if result.error is not None:
return 0, [], False
reasons = []
if result.verdict == _RppgVerdict.SYNTHETIC:
suspicion = 70 + min(result.confidence * 30, 30)
reasons.append(r("rppg_synthetic",
f"No physiologically plausible heartbeat signal detected in facial skin tone (rPPG analysis, confidence={result.confidence:.2f}) β€” real video of a person almost always shows a faint pulse-driven color signal; synthetic/composited faces typically don't",
value=f"{result.confidence:.2f}"))
elif result.verdict == _RppgVerdict.HUMAN:
suspicion = max(30 - result.confidence * 30, 0)
reasons.append(r("rppg_human",
f"A physiologically plausible heartbeat signal was detected in facial skin tone (rPPG analysis, confidence={result.confidence:.2f}) β€” a strong signal of a real, live-recorded face",
value=f"{result.confidence:.2f}"))
else:
suspicion = 20
reasons.append(r("rppg_inconclusive",
"Pulse-signal analysis was inconclusive (video too short, face too small/unstable, or poor lighting) β€” not usable as a signal for this video"))
return min(suspicion, 100), reasons, True
def analyze_video_bytes(video_bytes, tmp_path):
# Stage 1 (roadmap): container metadata scan β€” cheap, doesn't need the
# file on disk or decoding, so run it first on the raw bytes.
meta_score, meta_reasons_raw, meta_available = video_metadata_detector.scan_video_metadata(video_bytes)
meta_reasons = [r(item["key"], item["text"], **item["params"]) for item in meta_reasons_raw]
with open(tmp_path, "wb") as f:
f.write(video_bytes)
wav_path = tmp_path + ".wav"
has_audio = audio_extractor.extract_audio(tmp_path, wav_path)
if not has_audio:
wav_path = None
cap = cv2.VideoCapture(tmp_path)
reasons_motion = []
reasons_face = []
suspicion = 0
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
fps = cap.get(cv2.CAP_PROP_FPS)
if not fps or fps <= 0:
fps = 25.0
if frame_count <= 1:
cap.release()
_safe_remove(tmp_path)
return {
"type": "video", "suspicion_score": 0, "authenticity_score": 0,
"verdict": "Could not read the video, or it is empty", "verdict_key": "video_unreadable", "verdict_level": "medium",
"categories": [],
}
sample_every = max(1, int(frame_count / 40))
# Stage 3c (roadmap): blink detection needs denser, evenly-timed
# sampling (~3 frames/sec) independent of the sparser motion/face
# cadence above, so it's tracked separately within the same pass.
blink_sample_every = max(1, int(round(fps / 3)))
max_blink_frames = 150
blink_frames = []
prev_gray = None
frame_diffs = [] # values only
frame_diffs_idx = [] # matching frame index, for temporal localization
face_edge_scores = []
face_crops_bgr = [] # keep a few real face crops to run the trained model on
idx = 0
while True:
ret, frame = cap.read()
if not ret:
break
if idx % blink_sample_every == 0 and len(blink_frames) < max_blink_frames:
blink_frames.append(frame.copy())
if idx % sample_every == 0:
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
if prev_gray is not None and prev_gray.shape == gray.shape:
diff = cv2.absdiff(gray, prev_gray)
frame_diffs.append(float(np.mean(diff)))
frame_diffs_idx.append(idx)
prev_gray = gray
faces = face_detector.detect_faces(frame)
for (x, y, w, h) in faces:
face_roi = gray[y:y + h, x:x + w]
if face_roi.size > 0:
edges = cv2.Laplacian(face_roi, cv2.CV_64F).var()
face_edge_scores.append(edges)
if len(face_crops_bgr) < 8 and w > 40 and h > 40:
face_crops_bgr.append(frame[y:y + h, x:x + w].copy())
idx += 1
cap.release()
# Run the rPPG (pulse-signal) check while the file still exists on disk
rppg_score, rppg_reasons, rppg_available = check_rppg(tmp_path)
_safe_remove(tmp_path)
# Stage 3c (roadmap): blink-rate check on the densely-sampled frames
# collected during the main pass above.
samples_per_second = fps / blink_sample_every if blink_sample_every else 0
blink_score, blink_reasons_raw, blink_available = blink_detector.analyze_blinks(blink_frames, samples_per_second)
blink_reasons = [r(item["key"], item["text"], **item["params"]) for item in blink_reasons_raw]
# Audio-based checks (added 2026-08): both need the extracted WAV,
# which is None if the video had no audio track β€” in that case both
# simply report unavailable rather than erroring.
voice_score, voice_reason_raw, voice_available = voice_deepfake_detector.classify_audio(wav_path) if wav_path else (0, None, False)
voice_reasons = [r(voice_reason_raw["key"], voice_reason_raw["text"], **voice_reason_raw["params"])] if voice_reason_raw else []
lipsync_score, lipsync_reason_raw, lipsync_available = lipsync_detector.analyze_lipsync(blink_frames, samples_per_second, wav_path)
lipsync_reasons = [r(lipsync_reason_raw["key"], lipsync_reason_raw["text"], **lipsync_reason_raw["params"])] if lipsync_reason_raw else []
if wav_path and os.path.exists(wav_path):
_safe_remove(wav_path)
# 1) Inter-frame motion consistency
if len(frame_diffs) >= 3:
diffs = np.array(frame_diffs)
motion_variance = float(np.var(diffs))
motion_mean = float(np.mean(diffs))
spike_ratio = float(np.sum(diffs > (motion_mean + 2 * np.std(diffs))) / len(diffs)) if np.std(diffs) > 0 else 0
if spike_ratio > 0.15:
suspicion += 30
reasons_motion.append(r("vid_motion_spikes",
f"Frequent sudden jumps in motion between frames (spike ratio={spike_ratio:.2f}) β€” may indicate cut/spliced clips or frame swapping",
value=f"{spike_ratio:.2f}"))
# Temporal localization: point at roughly *when* the single
# biggest jump happened, not just that jumps exist somewhere.
peak_i = int(np.argmax(diffs))
peak_frame_idx = frame_diffs_idx[peak_i]
peak_seconds = peak_frame_idx / fps
reasons_motion.append(r("vid_motion_peak_location",
f"The single largest motion jump is at approximately {peak_seconds:.1f}s into the video β€” worth reviewing that moment specifically",
seconds=f"{peak_seconds:.1f}"))
else:
reasons_motion.append(r("vid_motion_smooth",
f"Motion between frames is smooth and natural (spike ratio={spike_ratio:.2f})",
value=f"{spike_ratio:.2f}"))
reasons_motion.append(r("vid_motion_stats",
f"Average frame-to-frame change: {motion_mean:.1f}, variance: {motion_variance:.1f}",
mean=f"{motion_mean:.1f}", variance=f"{motion_variance:.1f}"))
else:
reasons_motion.append(r("vid_motion_insufficient",
"Too few analyzable frames for a reliable motion assessment"))
# 2) Face-region sharpness consistency
if face_edge_scores:
face_scores = np.array(face_edge_scores)
face_variance = float(np.var(face_scores))
face_mean = float(np.mean(face_scores))
is_inconsistent = face_mean > 0 and face_variance > (face_mean ** 1.5)
if is_inconsistent:
suspicion += 25
reasons_face.append(r("face_inconsistent",
f"Large variance in facial detail sharpness across frames (variance={face_variance:.1f}) β€” a possible sign of a swapped/composited face (deepfake)",
value="35"))
else:
reasons_face.append(r("face_consistent",
f"Facial detail sharpness is consistent across frames (variance={face_variance:.1f}) β€” consistent with an uncomposited video",
value="10"))
reasons_face.append(r("face_detected_count",
f"Faces detected in {len(face_edge_scores)} sampled frames",
count=str(len(face_edge_scores))))
else:
reasons_face.append(r("face_none",
"No clear faces detected in the sampled frames β€” face-based analysis is unavailable for this video"))
# 3) Trained AI-detector model applied to sampled face crops
model_reasons = []
model_score = 0
model_available = False
if face_crops_bgr:
probs = []
for crop_bgr in face_crops_bgr:
crop_rgb = cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2RGB)
pil_crop = Image.fromarray(crop_rgb)
p = ai_detector.classify_image(pil_crop)
if p is not None:
probs.append(p)
if probs:
model_available = True
model_score = float(np.mean(probs))
model_reasons.append(r("ai_model_video_score",
f"Trained model estimates an average {model_score:.0f}% probability of AI-generated/synthetic faces across {len(probs)} sampled face crops",
value=f"{model_score:.0f}", count=str(len(probs))))
# 3b) Second, face-specialized deepfake model applied to the same sampled face crops
face_df_reasons = []
face_df_score = 0
face_df_available = False
if face_crops_bgr:
probs2 = []
for crop_bgr in face_crops_bgr:
crop_rgb = cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2RGB)
pil_crop = Image.fromarray(crop_rgb)
p = deepfake_face_detector.classify_face(pil_crop)
if p is not None:
probs2.append(p)
if probs2:
face_df_available = True
face_df_score = float(np.mean(probs2))
face_df_reasons.append(r("face_deepfake_video_score",
f"Face-specialized deepfake model estimates an average {face_df_score:.0f}% probability across {len(probs2)} sampled face crops",
value=f"{face_df_score:.0f}", count=str(len(probs2))))
# 3c) Nonescape, noise-residual fingerprint, and resize-trace forensics
# β€” these are per-frame image detectors (added 2026-08), reused here on
# the same sampled face crops rather than duplicated. Picked
# specifically because they're the ones with real, documented
# standalone evidence (see nonescape_bridge.py / noise_residual_detector.py
# docstrings) β€” EfficientNet-B3 and clone-detection were deliberately
# left out of the video pipeline: EfficientNet-B3 only hit 57.4%
# balanced accuracy in this project's own testing (near coin-flip),
# and clone-detection was never standalone-tested at all.
nonescape_reasons, nonescape_score, nonescape_available = [], 0, False
noise_reasons, noise_score, noise_available = [], 0, False
pixel_reasons, pixel_score, pixel_available = [], 0, False
if face_crops_bgr:
ne_probs, noise_probs, pixel_probs = [], [], []
for crop_bgr in face_crops_bgr:
crop_rgb = cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2RGB)
pil_crop = Image.fromarray(crop_rgb)
p_ne = nonescape_bridge.classify_image(pil_crop)
if p_ne is not None:
ne_probs.append(p_ne)
p_noise = noise_residual_detector.classify_image(pil_crop)
if p_noise is not None:
noise_probs.append(p_noise)
p_pixel = pixel_forensics_detector.classify_image(pil_crop)
if p_pixel is not None:
pixel_probs.append(p_pixel["combined_score"])
if ne_probs:
nonescape_available = True
nonescape_score = float(np.mean(ne_probs))
nonescape_reasons.append(r("nonescape_video_score",
f"Nonescape model estimates an average {nonescape_score:.0f}% probability across {len(ne_probs)} sampled face crops",
value=f"{nonescape_score:.0f}", count=str(len(ne_probs))))
if noise_probs:
noise_available = True
noise_score = float(np.mean(noise_probs))
noise_reasons.append(r("noise_residual_video_score",
f"Noise fingerprint analysis estimates an average {noise_score:.0f}% probability of an unnatural noise pattern across {len(noise_probs)} sampled face crops",
value=f"{noise_score:.0f}", count=str(len(noise_probs))))
if pixel_probs:
pixel_available = True
pixel_score = float(np.mean(pixel_probs))
pixel_reasons.append(r("pixel_forensics_video_score",
f"Resize-trace analysis estimates an average {pixel_score:.0f}% probability of interpolation/upscaling artifacts across {len(pixel_probs)} sampled face crops",
value=f"{pixel_score:.0f}", count=str(len(pixel_probs))))
# Blend all available signals, weighted by how much each is trusted:
# rPPG (biology-based) and the two trained models carry more weight
# than the hand-tuned motion/face heuristics. Weights are normalized
# by whichever signals actually loaded, so a missing model doesn't
# just get silently dropped β€” the remaining signals fill in its share.
# meta (container metadata) and blink (behavioral biometrics) added
# 2026-08 per the video-pipeline roadmap β€” both at a moderate weight
# since each is real evidence but narrower/noisier than the trained
# models or rPPG (meta only fires strongly when a named app is found;
# blink rate is affected by clip length and camera angle too).
base_weights = VIDEO_WEIGHTS
available_scores = {"heuristic": min(suspicion, 100)}
if model_available:
available_scores["model"] = model_score
if face_df_available:
available_scores["face_df"] = face_df_score
if rppg_available:
available_scores["rppg"] = rppg_score
if meta_available:
available_scores["meta"] = meta_score
if blink_available:
available_scores["blink"] = blink_score
if nonescape_available:
available_scores["nonescape"] = nonescape_score
if noise_available:
available_scores["noise"] = noise_score
if pixel_available:
available_scores["pixel"] = pixel_score
if voice_available:
available_scores["voice"] = voice_score
if lipsync_available:
available_scores["lipsync"] = lipsync_score
weight_sum = sum(base_weights[k] for k in available_scores)
final_score = sum(available_scores[k] * base_weights[k] for k in available_scores) / weight_sum
final_score = min(final_score, 100)
authenticity = 100 - final_score
if final_score >= 55:
verdict = "This video shows strong signals of being composited or manipulated (possible deepfake)"
verdict_level = "high"
verdict_key = "video_high"
elif final_score >= 25:
verdict = "Moderate signals β€” inconclusive, further review recommended"
verdict_level = "medium"
verdict_key = "video_medium"
else:
verdict = "This video is likely authentic / uncomposited"
verdict_level = "low"
verdict_key = "video_low"
categories = [
{"key": "cat_motion", "name": "Optical Flow β€” Frame Motion Analysis", "score": min(round(suspicion if reasons_motion else 0), 100), "reasons": reasons_motion},
{"key": "cat_face", "name": "Laplacian Edge β€” Face Sharpness Analysis", "score": 0, "reasons": reasons_face},
]
if model_available:
backend = ai_detector.get_active_backend() or "model"
categories.append({"key": "cat_ai_model", "name": f"AI Detector β€” {backend} (CLIP/HF ensemble)", "score": round(model_score), "reasons": model_reasons})
if face_df_available:
categories.append({"key": "cat_face_deepfake", "name": "Face Deepfake Detector (ViT)", "score": round(face_df_score), "reasons": face_df_reasons})
if rppg_available:
categories.append({"key": "cat_rppg", "name": "BioVerify rPPG β€” Pulse Signal Analysis", "score": round(rppg_score), "reasons": rppg_reasons})
if meta_available:
categories.append({"key": "cat_vid_meta", "name": "Container Metadata β€” Editing App Detection", "score": round(meta_score), "reasons": meta_reasons})
if blink_available:
categories.append({"key": "cat_blink", "name": "Blink Rate β€” Behavioral Biometrics (MediaPipe)", "score": round(blink_score), "reasons": blink_reasons})
if nonescape_available:
categories.append({"key": "cat_nonescape", "name": "Nonescape (DINOv2+EfficientNet)", "score": round(nonescape_score), "reasons": nonescape_reasons})
if noise_available:
categories.append({"key": "cat_noise_residual", "name": "Noise Residual Analysis", "score": round(noise_score), "reasons": noise_reasons})
if pixel_available:
categories.append({"key": "cat_pixel_forensics", "name": "Pixel Forensics (Resize Trace)", "score": round(pixel_score), "reasons": pixel_reasons})
if voice_available:
categories.append({"key": "cat_voice_deepfake", "name": "Voice Deepfake Detector", "score": round(voice_score), "reasons": voice_reasons})
if lipsync_available:
categories.append({"key": "cat_lipsync", "name": "Lip-Sync Consistency", "score": round(lipsync_score), "reasons": lipsync_reasons})
return {
"type": "video",
"suspicion_score": round(final_score),
"authenticity_score": round(authenticity),
"verdict": verdict,
"verdict_key": verdict_key,
"verdict_level": verdict_level,
"categories": categories,
}