Psychomods/forensic-lab
0
1"""2╔══════════════════════════════════════════════════════════════════════╗3║ PSYCHOMODS CYBER FORENSIC LAB – v3.0 ║4║ Advanced Image Manipulation Detection System ║5║ https://psychomods.infy.uk ║6╠══════════════════════════════════════════════════════════════════════╣7║ Install: ║8║ pip install streamlit pillow opencv-python-headless numpy ║9║ matplotlib reportlab scipy scikit-image plotly ║10║ mediapipe ║11╚══════════════════════════════════════════════════════════════════════╝12"""13 14# ── stdlib ─────────────────────────────────────────────────────────────15import io, math, time, hashlib, zipfile, json, os, struct, re16from datetime import datetime, timezone17from collections import defaultdict18 19# ── third-party ────────────────────────────────────────────────────────20import cv221import numpy as np22import matplotlib23matplotlib.use("Agg")24import matplotlib.pyplot as plt25import matplotlib.patches as mpatches26from matplotlib.gridspec import GridSpec27import plotly.graph_objects as go28import plotly.express as px29from PIL import Image, ImageChops, ImageEnhance, ImageFilter, ExifTags30from scipy.ndimage import uniform_filter, gaussian_filter31# scipy.stats unused imports removed32from skimage.feature import local_binary_pattern33# estimate_sigma replaced with pure-numpy version for compatibility34# skimage.measure unused35from reportlab.platypus import (36 SimpleDocTemplate, Paragraph, Spacer, Table,37 TableStyle, HRFlowable, KeepTogether38)39from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle40from reportlab.lib import colors as rl_colors41from reportlab.lib.units import mm42from reportlab.lib.pagesizes import A443import streamlit as st44import streamlit.components.v1 as components45 46# ── optional mediapipe ─────────────────────────────────────────────────47try:48 import mediapipe as mp49 MP_AVAILABLE = True50except ImportError:51 MP_AVAILABLE = False52 53# ══════════════════════════════════════════════════════════════════════54# PAGE CONFIG55# ══════════════════════════════════════════════════════════════════════56st.set_page_config(57 page_title="Psychomods Cyber Forensic Lab v3",58 page_icon="🧠",59 layout="wide",60 initial_sidebar_state="expanded",61)62 63# ══════════════════════════════════════════════════════════════════════64# MATRIX RAIN65# ══════════════════════════════════════════════════════════════════════66components.html("""67<canvas id="mx"></canvas>68<style>69 #mx{position:fixed;top:0;left:0;width:100vw;height:100vh;70 z-index:-1;pointer-events:none;background:#000;}71</style>72<script>73 const c=document.getElementById("mx"),ctx=c.getContext("2d");74 function rs(){c.width=innerWidth;c.height=innerHeight;}75 rs(); window.addEventListener("resize",rs);76 const CH="01アイウエオカキサシスセソABCDEF01";77 let drops=[];78 function init(){drops=Array(Math.ceil(c.width/13)).fill(1);}79 init(); window.addEventListener("resize",init);80 function draw(){81 ctx.fillStyle="rgba(0,0,0,0.05)";82 ctx.fillRect(0,0,c.width,c.height);83 ctx.font="13px monospace";84 drops.forEach((y,i)=>{85 ctx.fillStyle=i%5===0?"#ffffff":"#00ff9c";86 ctx.fillText(CH[Math.floor(Math.random()*CH.length)],i*13,y*13);87 if(y*13>c.height&&Math.random()>.975)drops[i]=0;88 drops[i]++;89 });90 }91 setInterval(draw,35);92</script>93""", height=0, width=0)94 95# ══════════════════════════════════════════════════════════════════════96# GLOBAL CSS97# ══════════════════════════════════════════════════════════════════════98st.markdown("""99<style>100html,body,[data-testid="stAppViewContainer"]{background:transparent!important;}101[data-testid="stAppViewContainer"]>.main{background:rgba(0,0,0,0.6);}102[data-testid="stSidebar"]{background:rgba(0,0,0,0.88)!important;}103::-webkit-scrollbar{width:5px;}104::-webkit-scrollbar-thumb{background:#00ff9c;border-radius:3px;}105 106.hdr{background:rgba(0,0,0,0.85);backdrop-filter:blur(20px);107 border-bottom:1px solid rgba(0,255,156,.5);padding:24px 30px 18px;108 border-radius:0 0 20px 20px;margin-bottom:24px;text-align:center;}109.hdr-t{font-size:38px;font-weight:900;letter-spacing:4px;color:#00ff9c;110 text-shadow:0 0 24px #00ff9c99;font-family:'Courier New',monospace;}111.hdr-s{color:#9affd7;font-size:12px;letter-spacing:2px;margin-top:4px;}112 113.sec{font-size:15px;font-weight:700;color:#00ff9c;letter-spacing:2px;114 border-left:4px solid #00ff9c;padding:6px 0 6px 14px;margin:28px 0 12px;115 background:rgba(0,255,156,.06);border-radius:0 8px 8px 0;116 font-family:'Courier New',monospace;}117 118.glass{background:rgba(0,0,0,.45);backdrop-filter:blur(18px);119 border:1px solid rgba(0,255,156,.3);border-radius:14px;padding:20px;margin-bottom:12px;}120 121.vcard{text-align:center;padding:18px;border-radius:12px;122 border:1px solid rgba(0,255,156,.3);background:rgba(0,0,0,.5);}123 124.mrow{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0;}125.pill{background:rgba(0,255,156,.08);border:1px solid rgba(0,255,156,.3);126 border-radius:8px;padding:6px 12px;font-family:'Courier New',monospace;127 font-size:12px;color:#9affd7;}128.pill b{color:#00ff9c;}129 130.warn{background:rgba(255,100,0,.12);border:1px solid rgba(255,100,0,.4);131 border-radius:8px;padding:10px 14px;color:#ffaa66;font-size:13px;}132.ok{background:rgba(0,200,100,.1);border:1px solid rgba(0,200,100,.4);133 border-radius:8px;padding:10px 14px;color:#66ffaa;font-size:13px;}134 135div[data-testid="stFileUploader"]{136 border:1px dashed rgba(0,255,156,.4)!important;137 border-radius:12px;padding:8px;background:rgba(0,0,0,.3);}138div.stButton>button{139 background:linear-gradient(135deg,#00ff9c22,#00cc7a33);140 color:#00ff9c;border:1px solid #00ff9c;border-radius:10px;141 font-weight:700;font-family:'Courier New',monospace;142 letter-spacing:1px;padding:10px 26px;transition:all .25s;}143div.stButton>button:hover{background:#00ff9c;color:#000;box-shadow:0 0 18px #00ff9c88;}144div[data-testid="stProgress"]>div>div{145 background:linear-gradient(90deg,#00ff9c,#00cc7a)!important;}146label,.stMarkdown p{color:#c8ffe8!important;}147div[data-testid="stExpander"]{148 border:1px solid rgba(0,255,156,.25)!important;149 border-radius:10px;background:rgba(0,0,0,.35);}150.stTabs [data-baseweb="tab"]{color:#9affd7;font-family:'Courier New',monospace;}151.stTabs [aria-selected="true"]{color:#00ff9c!important;}152[data-testid="stMetric"]{153 background:rgba(0,255,156,.06);border:1px solid rgba(0,255,156,.2);154 border-radius:10px;padding:10px;}155[data-testid="stMetricValue"]{color:#00ff9c!important;}156[data-testid="stMetricLabel"]{color:#9affd7!important;}157 158.footer{position:fixed;bottom:0;left:0;width:100%;159 background:rgba(0,0,0,.82);backdrop-filter:blur(15px);160 border-top:1px solid rgba(0,255,156,.35);padding:9px;161 text-align:center;color:#00ff9c;font-size:11px;162 font-family:'Courier New',monospace;z-index:9999;}163html{scroll-behavior:smooth;}164</style>165<div class="hdr">166 <div class="hdr-t">🧠 PSYCHOMODS CYBER FORENSIC LAB</div>167 <div class="hdr-s">⚡ v3.0 · Advanced Image Manipulation Detection System ⚡</div>168</div>169""", unsafe_allow_html=True)170 171 172# ══════════════════════════════════════════════════════════════════════173# UTILITIES174# ══════════════════════════════════════════════════════════════════════175def sec(title: str):176 st.markdown(f'<div class="sec">▶ {title}</div>', unsafe_allow_html=True)177 178def pill_row(pairs: list):179 html = '<div class="mrow">'180 for k, v in pairs:181 html += f'<div class="pill">{k} <b>{v}</b></div>'182 html += '</div>'183 st.markdown(html, unsafe_allow_html=True)184 185def pil_to_cv(img: Image.Image) -> np.ndarray:186 return cv2.cvtColor(np.array(img.convert("RGB")), cv2.COLOR_RGB2BGR)187 188def cv_to_pil(img: np.ndarray) -> Image.Image:189 if len(img.shape) == 2:190 return Image.fromarray(img)191 return Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))192 193def fig_to_pil(fig) -> Image.Image:194 buf = io.BytesIO()195 fig.savefig(buf, format="png", bbox_inches="tight", facecolor="#000", dpi=110)196 buf.seek(0); plt.close(fig)197 return Image.open(buf).copy()198 199def dark_fig(w=5, h=3):200 fig, ax = plt.subplots(figsize=(w, h), facecolor="#000")201 ax.set_facecolor("#000")202 for sp in ax.spines.values(): sp.set_edgecolor("#00ff9c33")203 ax.tick_params(colors="#9affd7", labelsize=8)204 ax.xaxis.label.set_color("#9affd7")205 ax.yaxis.label.set_color("#9affd7")206 return fig, ax207 208def hash_bytes(data: bytes):209 return hashlib.sha256(data).hexdigest(), hashlib.md5(data).hexdigest()210 211 212# ══════════════════════════════════════════════════════════════════════213# ── MODULE 1 · ELA ────────────────────────────────────────────────────214# ══════════════════════════════════════════════════════════════════════215def ela(img: Image.Image, quality=92, scale=20) -> Image.Image:216 rgb = img.convert("RGB")217 buf = io.BytesIO()218 rgb.save(buf, "JPEG", quality=quality); buf.seek(0)219 diff = ImageChops.difference(rgb, Image.open(buf))220 return ImageEnhance.Brightness(diff).enhance(scale)221 222def ela_heatmap(e: Image.Image) -> Image.Image:223 gray = cv2.cvtColor(np.array(e.convert("RGB")), cv2.COLOR_RGB2GRAY)224 return cv_to_pil(cv2.applyColorMap(gray, cv2.COLORMAP_JET))225 226def ela_stats(e: Image.Image):227 a = np.array(e.convert("RGB")).astype(float)228 mean = float(np.mean(a)); std = float(np.std(a))229 p95 = float(np.percentile(a, 95))230 prob = min(100.0, mean * 2.2)231 if mean < 8: v,c = "LIKELY AUTHENTIC", "#22c55e"232 elif mean < 18: v,c = "MINOR EDITING POSSIBLE", "#84cc16"233 elif mean < 30: v,c = "POSSIBLY EDITED", "#facc15"234 elif mean < 45: v,c = "SUSPICIOUS / EDITED", "#f97316"235 else: v,c = "LIKELY MANIPULATED", "#ef4444"236 return mean, std, p95, prob, v, c237 238def ela_region_detect(img: Image.Image, e: Image.Image,239 thresh=20, min_area=300):240 out = pil_to_cv(img)241 gray = cv2.cvtColor(np.array(e.convert("RGB")), cv2.COLOR_RGB2GRAY)242 _, th = cv2.threshold(gray, thresh, 255, cv2.THRESH_BINARY)243 k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))244 th = cv2.morphologyEx(th, cv2.MORPH_CLOSE, k, iterations=2)245 cnts, _ = cv2.findContours(th, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)246 n = 0247 for c in cnts:248 if cv2.contourArea(c) > min_area:249 x,y,w,h = cv2.boundingRect(c)250 cv2.rectangle(out,(x,y),(x+w,y+h),(0,0,255),2)251 cv2.putText(out,f"#{n+1}",(x+4,y+18),252 cv2.FONT_HERSHEY_SIMPLEX,.55,(0,0,255),1)253 n += 1254 cv2.putText(out,f"Suspicious: {n}",(8,22),255 cv2.FONT_HERSHEY_SIMPLEX,.6,(0,255,156),2)256 return cv_to_pil(out), n257 258 259# ══════════════════════════════════════════════════════════════════════260# ── MODULE 2 · CLONE / COPY-MOVE ─────────────────────────────────────261# ══════════════════════════════════════════════════════════════════════262def clone_detect(img: Image.Image):263 gray = cv2.cvtColor(np.array(img.convert("RGB")), cv2.COLOR_RGB2GRAY)264 orb = cv2.ORB_create(nfeatures=8000)265 kp, des = orb.detectAndCompute(gray, None)266 out = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)267 if des is None or len(kp) < 10:268 return cv_to_pil(out), 0269 bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)270 seen = set(); n = 0271 for m in sorted(bf.match(des,des), key=lambda x:x.distance):272 qi, ti = m.queryIdx, m.trainIdx273 if qi == ti: continue274 key = (min(qi,ti),max(qi,ti))275 if key in seen: continue276 seen.add(key)277 p1 = tuple(map(int, kp[qi].pt))278 p2 = tuple(map(int, kp[ti].pt))279 if math.hypot(p1[0]-p2[0],p1[1]-p2[1]) > 20:280 cv2.line(out, p1, p2, (0,255,100), 1)281 cv2.circle(out, p1, 3, (0,200,255), -1)282 cv2.circle(out, p2, 3, (255,80,0), -1)283 n += 1284 if n >= 40: break285 cv2.putText(out,f"Clone pairs: {n}",(8,22),286 cv2.FONT_HERSHEY_SIMPLEX,.6,(0,255,156),2)287 return cv_to_pil(out), n288 289 290# ══════════════════════════════════════════════════════════════════════291# ── MODULE 3 · NOISE RESIDUAL ─────────────────────────────────────────292# ══════════════════════════════════════════════════════════════════════293def _noise_sigma_mad(arr: np.ndarray) -> float:294 """Estimate noise sigma via Median Absolute Deviation on the Laplacian.295 Pure numpy — no skimage.restoration dependency."""296 sigmas = []297 for c in range(arr.shape[2]):298 lap = cv2.Laplacian(arr[:,:,c], cv2.CV_32F)299 mad = float(np.median(np.abs(lap - np.median(lap))))300 sigmas.append(mad / 0.6745)301 return float(np.mean(sigmas))302 303def noise_residual(img: Image.Image):304 arr = np.array(img.convert("RGB")).astype(np.float32)305 blur = cv2.GaussianBlur(arr,(5,5),0)306 res = np.clip(np.abs(arr-blur)*4,0,255).astype(np.uint8)307 sig = _noise_sigma_mad(arr)308 heat = cv2.applyColorMap(cv2.cvtColor(res,cv2.COLOR_RGB2GRAY), cv2.COLORMAP_HOT)309 cv2.putText(heat,f"σ={sig:.2f}",(8,22),cv2.FONT_HERSHEY_SIMPLEX,.6,(0,255,156),2)310 return cv_to_pil(heat), sig311 312 313# ══════════════════════════════════════════════════════════════════════314# ── MODULE 4 · EDGE ANALYSIS ─────────────────────────────────────────315# ══════════════════════════════════════════════════════════════════════316def edge_analysis(img: Image.Image):317 gray = cv2.cvtColor(np.array(img.convert("RGB")),cv2.COLOR_RGB2GRAY)318 edges = cv2.Canny(cv2.GaussianBlur(gray,(3,3),0), 80, 180)319 density = float(np.count_nonzero(edges))/edges.size*100320 out = np.zeros((*edges.shape,3),dtype=np.uint8)321 out[edges>0] = [0,255,156]322 cv2.putText(out,f"Density: {density:.1f}%",(8,22),323 cv2.FONT_HERSHEY_SIMPLEX,.6,(255,200,0),2)324 return cv_to_pil(out), density325 326 327# ══════════════════════════════════════════════════════════════════════328# ── MODULE 5 · FFT FREQUENCY SPECTRUM ────────────────────────────────329# ══════════════════════════════════════════════════════════════════════330def fft_analysis(img: Image.Image) -> Image.Image:331 gray = cv2.cvtColor(np.array(img.convert("RGB")),cv2.COLOR_RGB2GRAY).astype(np.float32)332 mag = 20*np.log1p(np.abs(np.fft.fftshift(np.fft.fft2(gray))))333 norm = cv2.normalize(mag,None,0,255,cv2.NORM_MINMAX).astype(np.uint8)334 return cv_to_pil(cv2.applyColorMap(norm, cv2.COLORMAP_INFERNO))335 336 337# ══════════════════════════════════════════════════════════════════════338# ── MODULE 6 · DCT BLOCK ANALYSIS ────────────────────────────────────339# ══════════════════════════════════════════════════════════════════════340def dct_analysis(img: Image.Image):341 gray = cv2.cvtColor(np.array(img.convert("RGB")),cv2.COLOR_RGB2GRAY).astype(np.float32)342 h,w = gray.shape343 bh,bw = h-h%8, w-w%8344 gray = gray[:bh,:bw]345 emap = np.zeros_like(gray)346 bmeans = []347 for r in range(0,bh,8):348 for cc in range(0,bw,8):349 blk = gray[r:r+8,cc:cc+8]350 d = cv2.dct(blk)351 e = float(np.log1p(np.abs(d)).mean())352 emap[r:r+8,cc:cc+8] = e353 bmeans.append(e)354 score = float(np.std(bmeans))355 norm = cv2.normalize(emap,None,0,255,cv2.NORM_MINMAX).astype(np.uint8)356 heat = cv2.applyColorMap(norm, cv2.COLORMAP_PLASMA)357 cv2.putText(heat,f"DCT inconsistency: {score:.2f}",(8,22),358 cv2.FONT_HERSHEY_SIMPLEX,.55,(0,255,156),2)359 return cv_to_pil(heat), score360 361 362# ══════════════════════════════════════════════════════════════════════363# ── MODULE 7 · LBP TEXTURE ───────────────────────────────────────────364# ══════════════════════════════════════════════════════════════════════365def lbp_analysis(img: Image.Image) -> Image.Image:366 gray = cv2.cvtColor(np.array(img.convert("RGB")),cv2.COLOR_RGB2GRAY)367 lbp = local_binary_pattern(gray, P=8, R=1.0, method="uniform")368 norm = cv2.normalize(lbp,None,0,255,cv2.NORM_MINMAX).astype(np.uint8)369 return cv_to_pil(cv2.applyColorMap(norm, cv2.COLORMAP_TWILIGHT_SHIFTED))370 371 372# ══════════════════════════════════════════════════════════════════════373# ── MODULE 8 · JPEG GHOST ────────────────────────────────────────────374# ══════════════════════════════════════════════════════════════════════375def jpeg_ghost(img: Image.Image, qualities=(50,70,85,95)) -> Image.Image:376 orig = np.array(img.convert("RGB")).astype(np.float32)377 maps = []378 for q in qualities:379 buf = io.BytesIO()380 img.convert("RGB").save(buf,"JPEG",quality=q); buf.seek(0)381 comp = np.array(Image.open(buf)).astype(np.float32)382 maps.append(np.abs(orig-comp).mean(axis=2))383 norm = cv2.normalize(np.mean(maps,axis=0),None,0,255,cv2.NORM_MINMAX).astype(np.uint8)384 return cv_to_pil(cv2.applyColorMap(norm, cv2.COLORMAP_OCEAN))385 386 387# ══════════════════════════════════════════════════════════════════════388# ── MODULE 9 · LUMINANCE GRADIENT ────────────────────────────────────389# ══════════════════════════════════════════════════════════════════════390def luminance_gradient(img: Image.Image) -> Image.Image:391 gray = cv2.cvtColor(np.array(img.convert("RGB")),cv2.COLOR_RGB2GRAY).astype(np.float32)392 mag = cv2.magnitude(cv2.Sobel(gray,cv2.CV_32F,1,0,ksize=3),393 cv2.Sobel(gray,cv2.CV_32F,0,1,ksize=3))394 norm = cv2.normalize(mag,None,0,255,cv2.NORM_MINMAX).astype(np.uint8)395 return cv_to_pil(cv2.applyColorMap(norm, cv2.COLORMAP_MAGMA))396 397 398# ══════════════════════════════════════════════════════════════════════399# ── MODULE 10 · RGB HISTOGRAM ────────────────────────────────────────400# ══════════════════════════════════════════════════════════════════════401def histogram_plot(img: Image.Image) -> Image.Image:402 arr = np.array(img.convert("RGB"))403 fig, ax = dark_fig(5,3)404 for name,idx,col in [("R",0,"#ff4444"),("G",1,"#44ff88"),("B",2,"#4488ff")]:405 ax.plot(cv2.calcHist([arr],[idx],None,[256],[0,256]).flatten(),406 color=col, alpha=.85, lw=1.2, label=name)407 ax.legend(facecolor="#111",edgecolor="#00ff9c",labelcolor="#fff",fontsize=8)408 ax.set_xlim(0,255); ax.set_title("RGB Histogram",color="#00ff9c",fontsize=10)409 return fig_to_pil(fig)410 411 412# ══════════════════════════════════════════════════════════════════════413# ── MODULE 11 · STEGANOGRAPHY DETECTION ──────────────────────────────414# ══════════════════════════════════════════════════════════════════════415def steg_lsb_planes(img: Image.Image):416 """Extract all 8 bit-planes per channel and visualise the LSB plane."""417 arr = np.array(img.convert("RGB"))418 # LSB plane (bit 0)419 lsb = (arr & 1) * 255420 out = lsb.astype(np.uint8)421 return Image.fromarray(out)422 423def steg_chi_square(img: Image.Image):424 """425 Chi-square attack on LSB steganography.426 Returns (score, p_value, is_suspicious, message).427 High chi-square score → uniform LSB distribution → hidden data likely.428 """429 arr = np.array(img.convert("RGB")).flatten()430 # pair of value groups: (0,1),(2,3),(4,5),...431 scores = []432 for ch_start in range(0, len(arr), len(arr)//3):433 ch = arr[ch_start:ch_start+len(arr)//3]434 observed = np.bincount(ch, minlength=256).astype(float)435 # Chi-square on even/odd pairs436 pairs_obs = []437 pairs_exp = []438 for i in range(0, 256, 2):439 total = observed[i]+observed[i+1]440 pairs_obs.extend([observed[i], observed[i+1]])441 pairs_exp.extend([total/2, total/2])442 pairs_obs = np.array(pairs_obs)443 pairs_exp = np.array(pairs_exp)444 mask = pairs_exp > 0445 chi2 = float(np.sum((pairs_obs[mask]-pairs_exp[mask])**2/pairs_exp[mask]))446 scores.append(chi2)447 avg_chi2 = float(np.mean(scores))448 # normalise to 0-100 suspicion score (lower chi2 = more suspicious)449 susp = max(0.0, 100.0 - min(avg_chi2/5000*100, 100))450 suspicious = susp > 60451 return avg_chi2, susp, suspicious452 453def steg_entropy_map(img: Image.Image) -> Image.Image:454 """Local entropy map — high entropy blobs in LSB plane indicate hidden data."""455 gray = cv2.cvtColor(np.array(img.convert("RGB")),cv2.COLOR_RGB2GRAY)456 lsb = (gray & 1).astype(np.float32)457 # sliding window entropy via uniform filter on squared values458 emap = np.zeros_like(lsb)459 win = 16460 p = uniform_filter(lsb, size=win)461 p = np.clip(p, 1e-7, 1.0 - 1e-7) # keep away from 0/1 boundaries462 with np.errstate(divide="ignore", invalid="ignore"):463 emap = -(p * np.log2(p) + (1 - p) * np.log2(1 - p))464 emap = np.nan_to_num(emap, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32)465 norm = cv2.normalize(emap, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)466 heat = cv2.applyColorMap(norm, cv2.COLORMAP_VIRIDIS)467 return cv_to_pil(heat)468 469def steg_bit_planes(img: Image.Image) -> Image.Image:470 """Show all 8 bit-planes of the grayscale image in a grid."""471 gray = cv2.cvtColor(np.array(img.convert("RGB")),cv2.COLOR_RGB2GRAY)472 fig, axes = plt.subplots(2,4,figsize=(10,5),facecolor="#000")473 for bit in range(8):474 ax = axes[bit//4][bit%4]475 plane = ((gray >> bit) & 1)*255476 ax.imshow(plane, cmap="gray", vmin=0, vmax=255)477 ax.set_title(f"Bit {bit}", color="#00ff9c", fontsize=9)478 ax.axis("off")479 ax.set_facecolor("#000")480 fig.patch.set_facecolor("#000")481 plt.tight_layout()482 return fig_to_pil(fig)483 484 485# ══════════════════════════════════════════════════════════════════════486# ── MODULE 12 · METADATA TAMPERING TIMELINE ──────────────────────────487# ══════════════════════════════════════════════════════════════════════488def extract_exif_full(img: Image.Image) -> dict:489 try:490 raw = img._getexif()491 if not raw: return {}492 out = {}493 for tid, val in raw.items():494 tag = ExifTags.TAGS.get(tid, str(tid))495 if isinstance(val, bytes):496 try: val = val.decode("utf-8","replace")497 except: val = val.hex()498 out[tag] = str(val)[:300]499 return out500 except Exception:501 return {}502 503def metadata_timeline(exif: dict) -> tuple:504 """505 Cross-check EXIF dates and software fields for inconsistencies.506 Returns (events list, anomalies list, risk_score 0-100).507 """508 DATE_TAGS = ["DateTime","DateTimeOriginal","DateTimeDigitized",509 "GPSDateStamp","CreateDate","ModifyDate"]510 SW_TAGS = ["Software","ProcessingSoftware","HostComputer"]511 EDITING_KW = ["photoshop","lightroom","gimp","affinity","capture",512 "adobe","snapseed","vsco","pixelmator","paint.net",513 "darktable","rawtherapee","luminar","canva"]514 515 events = []516 anomalies = []517 dates = {}518 software = []519 520 for tag in DATE_TAGS:521 if tag in exif:522 val = exif[tag].strip()523 events.append({"tag": tag, "value": val, "type": "date"})524 try:525 # EXIF datetime: "YYYY:MM:DD HH:MM:SS"526 dt = datetime.strptime(val[:19], "%Y:%m:%d %H:%M:%S")527 dates[tag] = dt528 except Exception:529 pass530 531 for tag in SW_TAGS:532 if tag in exif:533 val = exif[tag].strip()534 software.append(val)535 events.append({"tag": tag, "value": val, "type": "software"})536 537 # GPS538 if "GPSInfo" in exif:539 events.append({"tag":"GPSInfo","value":"GPS coordinates present","type":"gps"})540 541 # Anomaly: editing software detected542 for sw in software:543 for kw in EDITING_KW:544 if kw in sw.lower():545 anomalies.append(f"Editing software detected: '{sw}'")546 break547 548 # Anomaly: date inconsistencies549 if len(dates) >= 2:550 vals = list(dates.values())551 for i in range(len(vals)):552 for j in range(i+1, len(vals)):553 diff = abs((vals[i]-vals[j]).total_seconds())554 if diff > 86400: # >1 day difference555 anomalies.append(556 f"Date mismatch: {list(dates.keys())[i]} vs "557 f"{list(dates.keys())[j]} differ by "558 f"{diff/3600:.1f} hours")559 560 # Anomaly: future dates561 now_naive = datetime.now() # naive, matches EXIF parsed datetimes562 for tag, dt in dates.items():563 if dt > now_naive:564 anomalies.append(f"Future date in {tag}: {dt}")565 566 # Anomaly: year 1970 / epoch567 for tag, dt in dates.items():568 if dt.year < 2000:569 anomalies.append(f"Suspicious early date in {tag}: {dt.year}")570 571 # Risk score572 risk = min(100, len(anomalies)*25 + (10 if software else 0))573 return events, anomalies, risk574 575 576# ══════════════════════════════════════════════════════════════════════577# ── MODULE 13 · SPLICING BOUNDARY DETECTION ──────────────────────────578# ══════════════════════════════════════════════════════════════════════579def splicing_boundary(img: Image.Image) -> tuple:580 """581 Detect splicing boundaries using:582 1. Noise inconsistency map (block-wise σ)583 2. Illumination gradient discontinuity584 3. Combined heatmap585 Returns (heatmap_pil, splice_score 0-100)586 """587 arr = np.array(img.convert("RGB")).astype(np.float32)588 gray = cv2.cvtColor(arr.astype(np.uint8), cv2.COLOR_RGB2GRAY).astype(np.float32)589 h, w = gray.shape590 591 # ── Block-wise noise sigma map ──────────────────────────────────592 bsize = 32593 nmap = np.zeros((h, w), dtype=np.float32)594 sigmas = []595 for r in range(0, h-bsize, bsize//2):596 for cc in range(0, w-bsize, bsize//2):597 blk = gray[r:r+bsize, cc:cc+bsize]598 # noise estimate: median absolute deviation of Laplacian599 lap = cv2.Laplacian(blk, cv2.CV_32F)600 s = float(np.median(np.abs(lap - np.median(lap)))) / 0.6745601 nmap[r:r+bsize, cc:cc+bsize] = s602 sigmas.append(s)603 604 # ── Illumination map (local mean luminance) ─────────────────────605 illum = cv2.GaussianBlur(gray, (31,31), 0)606 # gradient of illumination607 gx = cv2.Sobel(illum, cv2.CV_32F, 1, 0, ksize=5)608 gy = cv2.Sobel(illum, cv2.CV_32F, 0, 1, ksize=5)609 illum_grad = cv2.magnitude(gx, gy)610 611 # ── Combine ─────────────────────────────────────────────────────612 noise_norm = cv2.normalize(nmap, None, 0, 1, cv2.NORM_MINMAX)613 illum_norm = cv2.normalize(illum_grad, None, 0, 1, cv2.NORM_MINMAX)614 combined = (noise_norm * 0.6 + illum_norm * 0.4)615 combined_n = cv2.normalize(combined, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)616 heat = cv2.applyColorMap(combined_n, cv2.COLORMAP_JET)617 618 # splice score: std of block sigmas (high std = inconsistent noise = spliced)619 splice_score = min(100.0, float(np.std(sigmas)) * 15)620 621 cv2.putText(heat, f"Splice score: {splice_score:.1f}",622 (8,22), cv2.FONT_HERSHEY_SIMPLEX, .6, (0,255,156), 2)623 return cv_to_pil(heat), splice_score624 625 626# ══════════════════════════════════════════════════════════════════════627# ── MODULE 14 · GAN / AI ARTIFACT DETECTION ──────────────────────────628# ══════════════════════════════════════════════════════════════════════629def gan_checkerboard(img: Image.Image):630 """631 Detect checkerboard artifacts in FFT — hallmark of transposed-convolution632 GAN generators (DCGAN, StyleGAN upsampling artefacts).633 Returns (annotated_fft_pil, cb_score 0-100, peaks_found int).634 """635 gray = cv2.cvtColor(np.array(img.convert("RGB")),cv2.COLOR_RGB2GRAY).astype(np.float32)636 f = np.fft.fftshift(np.fft.fft2(gray))637 mag = np.log1p(np.abs(f))638 h, w = mag.shape639 cx, cy = w//2, h//2640 641 # Normalise for display642 disp = cv2.normalize(mag,None,0,255,cv2.NORM_MINMAX).astype(np.uint8)643 heat = cv2.applyColorMap(disp, cv2.COLORMAP_INFERNO)644 645 # Look for periodic peaks at N/2, N/4 frequencies (GAN checkerboard)646 # Sample horizontal and vertical lines through centre647 h_line = mag[cy, :]648 v_line = mag[:, cx]649 peaks_found = 0650 cb_score = 0.0651 652 for line in [h_line, v_line]:653 half = len(line)//2654 # Check N/2 frequency spike655 n2 = half//2656 local = line[max(0,n2-3):n2+3]657 surr = np.concatenate([line[max(0,n2-20):max(0,n2-4)],658 line[n2+4:n2+20]])659 if len(surr) > 0 and local.max() > surr.mean()*1.5:660 peaks_found += 1661 cv2.line(heat,(0,cy),(w,cy),(0,255,156),1)662 cv2.line(heat,(cx,0),(cx,h),(0,255,156),1)663 664 cb_score = min(100.0, peaks_found * 35.0 + float(np.std(mag))*0.5)665 cv2.putText(heat,f"GAN CB score: {cb_score:.1f}",666 (8,22),cv2.FONT_HERSHEY_SIMPLEX,.55,(0,255,156),2)667 return cv_to_pil(heat), cb_score, peaks_found668 669def gan_texture_variance(img: Image.Image):670 """671 GAN-generated images tend to have unnaturally uniform texture.672 Measure local variance map and its global std.673 Very low global std → suspiciously uniform → possible GAN.674 """675 gray = cv2.cvtColor(np.array(img.convert("RGB")),cv2.COLOR_RGB2GRAY).astype(np.float32)676 # Local variance via box filter677 mu = uniform_filter(gray, size=15)678 mu2 = uniform_filter(gray**2, size=15)679 var = np.clip(mu2 - mu**2, 0, None)680 gstd = float(np.std(var))681 norm = cv2.normalize(var,None,0,255,cv2.NORM_MINMAX).astype(np.uint8)682 heat = cv2.applyColorMap(norm, cv2.COLORMAP_COOL)683 # GAN score: low gstd = uniform texture = suspicious684 gan_score = max(0.0, min(100.0, 100.0 - gstd * 0.8))685 cv2.putText(heat,f"Texture uniformity: {gan_score:.1f}",686 (8,22),cv2.FONT_HERSHEY_SIMPLEX,.55,(0,255,156),2)687 return cv_to_pil(heat), gan_score688 689def gan_color_inconsistency(img: Image.Image):690 """691 Detect unnatural colour distribution — GAN images often have692 shifted chrominance histograms and low inter-channel correlation.693 """694 arr = np.array(img.convert("RGB")).astype(np.float32)695 r, g, b = arr[:,:,0].flatten(), arr[:,:,1].flatten(), arr[:,:,2].flatten()696 # Correlation between channels (real photos tend to be highly correlated)697 corr_rg = float(np.corrcoef(r,g)[0,1])698 corr_rb = float(np.corrcoef(r,b)[0,1])699 corr_gb = float(np.corrcoef(g,b)[0,1])700 avg_corr = (corr_rg+corr_rb+corr_gb)/3701 702 # Build colour histogram plot703 fig, axes = plt.subplots(1,3,figsize=(9,2.5),facecolor="#000")704 for ax,(ch,col,name) in zip(axes,[(r,"#ff4444","R"),(g,"#44ff88","G"),(b,"#4488ff","B")]):705 ax.hist(ch, bins=64, color=col, alpha=.8, histtype="stepfilled")706 ax.set_facecolor("#000"); ax.set_title(name,color=col,fontsize=9)707 for sp in ax.spines.values(): sp.set_edgecolor("#333")708 ax.tick_params(colors="#9affd7",labelsize=7)709 fig.suptitle(f"Channel correlation: {avg_corr:.3f}",color="#00ff9c",fontsize=10)710 plt.tight_layout()711 712 # Score: very low or very high correlation can indicate GAN713 gan_score = max(0.0, min(100.0, (1.0-abs(avg_corr))*120))714 return fig_to_pil(fig), gan_score, avg_corr715 716def gan_frequency_fingerprint(img: Image.Image):717 """718 Real images follow a 1/f power spectrum.719 GAN images deviate — check slope of radially averaged power spectrum.720 """721 gray = cv2.cvtColor(np.array(img.convert("RGB")),cv2.COLOR_RGB2GRAY).astype(np.float32)722 f = np.fft.fftshift(np.fft.fft2(gray))723 power= np.abs(f)**2724 h,w = power.shape725 cy,cx= h//2, w//2726 max_r= min(cx,cy)727 radial_power = []728 for r in range(1,max_r,2):729 mask = np.zeros((h,w),dtype=bool)730 cv2.circle(mask.view(np.uint8),( cx,cy),r,1,-1)731 cv2.circle(mask.view(np.uint8),(cx,cy),max(0,r-2),0,-1)732 if mask.sum()>0:733 radial_power.append(float(power[mask].mean()))734 735 if len(radial_power) < 10:736 return None, 0.0737 738 rp = np.array(radial_power)739 freq = np.arange(1,len(rp)+1,dtype=float)740 log_f= np.log10(freq); log_p = np.log10(rp+1)741 # Linear fit — slope should be ~-2 to -3 for natural images742 slope,intercept = np.polyfit(log_f, log_p, 1)743 744 fig, ax = dark_fig(5,3)745 ax.plot(log_f, log_p, color="#00ff9c", lw=1.5, label="Power spectrum")746 ax.plot(log_f, slope*log_f+intercept, "--",color="#ff4444",lw=1,label=f"Fit slope={slope:.2f}")747 ax.set_xlabel("log10(frequency)"); ax.set_ylabel("log10(power)")748 ax.set_title("Radial Power Spectrum",color="#00ff9c",fontsize=10)749 ax.legend(facecolor="#111",edgecolor="#00ff9c",labelcolor="#fff",fontsize=8)750 751 # GAN score: slope significantly different from natural range (-1.5 to -3.5)752 gan_score = 0.0753 if slope > -1.0 or slope < -5.0:754 gan_score = min(100.0, abs(slope + 2.5)*30)755 return fig_to_pil(fig), gan_score756 757 758# ══════════════════════════════════════════════════════════════════════759# ── MODULE 15 · FACE / FACIAL LANDMARK ANALYSIS ──────────────────────760# ══════════════════════════════════════════════════════════════════════761def face_analysis_cv(img: Image.Image):762 """763 Face detection + eye asymmetry check using OpenCV Haar cascades.764 Works without mediapipe or dlib.765 Returns (annotated_pil, results_dict).766 """767 out = pil_to_cv(img)768 gray = cv2.cvtColor(out, cv2.COLOR_BGR2GRAY)769 770 face_cascade = cv2.CascadeClassifier(771 cv2.data.haarcascades + "haarcascade_frontalface_default.xml")772 eye_cascade = cv2.CascadeClassifier(773 cv2.data.haarcascades + "haarcascade_eye.xml")774 775 faces = face_cascade.detectMultiScale(gray,1.1,5,minSize=(60,60))776 results = {"faces":len(faces),"eyes_per_face":[],"asymmetry_scores":[],777 "anomalies":[]}778 779 for (fx,fy,fw,fh) in faces:780 cv2.rectangle(out,(fx,fy),(fx+fw,fy+fh),(0,255,156),2)781 roi_gray = gray[fy:fy+fh, fx:fx+fw]782 roi_col = out [fy:fy+fh, fx:fx+fw]783 eyes = eye_cascade.detectMultiScale(roi_gray,1.1,5,minSize=(20,20))784 results["eyes_per_face"].append(len(eyes))785 786 eye_centers = []787 for (ex,ey,ew,eh) in eyes:788 cv2.rectangle(roi_col,(ex,ey),(ex+ew,ey+eh),(255,100,0),1)789 eye_centers.append((ex+ew//2, ey+eh//2))790 791 # Eye asymmetry: vertical position difference792 if len(eye_centers)==2:793 ydiff = abs(eye_centers[0][1]-eye_centers[1][1])794 norm_ydiff = ydiff/fh*100795 results["asymmetry_scores"].append(norm_ydiff)796 if norm_ydiff > 8:797 results["anomalies"].append(798 f"Eye vertical asymmetry: {norm_ydiff:.1f}% of face height")799 # Eye size comparison800 if len(eyes)==2:801 area0 = eyes[0][2]*eyes[0][3]802 area1 = eyes[1][2]*eyes[1][3]803 size_ratio = max(area0,area1)/max(min(area0,area1),1)804 if size_ratio > 1.6:805 results["anomalies"].append(806 f"Eye size ratio: {size_ratio:.2f}x (unusual)")807 808 cv2.putText(out,f"Face: {len(eyes)} eyes",809 (fx,fy-8),cv2.FONT_HERSHEY_SIMPLEX,.5,(0,255,156),1)810 811 if len(faces)==0:812 cv2.putText(out,"No faces detected",(8,22),813 cv2.FONT_HERSHEY_SIMPLEX,.6,(255,200,0),2)814 815 return cv_to_pil(out), results816 817def face_blending_boundary(img: Image.Image):818 """819 Detect face-swap blending seams using:820 - Local colour variance discontinuity around detected face boundary821 - High-pass filtered difference map822 """823 arr = np.array(img.convert("RGB")).astype(np.float32)824 gray = cv2.cvtColor(arr.astype(np.uint8),cv2.COLOR_RGB2GRAY)825 826 # High-pass via unsharp masking827 blurred = cv2.GaussianBlur(arr,(15,15),0)828 high_pass = np.abs(arr - blurred)829 hp_gray = high_pass.mean(axis=2)830 831 # Local std map832 local_mean = uniform_filter(hp_gray, size=20)833 local_sq = uniform_filter(hp_gray**2, size=20)834 local_std = np.sqrt(np.clip(local_sq-local_mean**2, 0, None))835 836 norm = cv2.normalize(local_std,None,0,255,cv2.NORM_MINMAX).astype(np.uint8)837 heat = cv2.applyColorMap(norm, cv2.COLORMAP_HOT)838 839 seam_score = min(100.0, float(np.std(local_std))*8)840 cv2.putText(heat,f"Seam score: {seam_score:.1f}",841 (8,22),cv2.FONT_HERSHEY_SIMPLEX,.6,(0,255,156),2)842 return cv_to_pil(heat), seam_score843 844 845# ══════════════════════════════════════════════════════════════════════846# ── MODULE 16 · PIXEL DIFF (single-image & two-image) ────────────────847# ══════════════════════════════════════════════════════════════════════848def pixel_diff(imgA: Image.Image, imgB: Image.Image, amplify=5):849 """Absolute pixel difference between two images (resized to match)."""850 sz = (min(imgA.width,imgB.width), min(imgA.height,imgB.height))851 a = np.array(imgA.convert("RGB").resize(sz)).astype(np.int16)852 b = np.array(imgB.convert("RGB").resize(sz)).astype(np.int16)853 diff = np.abs(a-b).astype(np.uint8)854 diff_amp = np.clip(diff*amplify,0,255).astype(np.uint8)855 mean_diff = float(np.mean(diff))856 heat = cv2.applyColorMap(857 cv2.cvtColor(diff_amp,cv2.COLOR_RGB2GRAY), cv2.COLORMAP_JET)858 cv2.putText(heat,f"Mean diff: {mean_diff:.2f}",859 (8,22),cv2.FONT_HERSHEY_SIMPLEX,.6,(0,255,156),2)860 return cv_to_pil(heat), mean_diff861 862 863# ══════════════════════════════════════════════════════════════════════864# ── MODULE 17 · FILE INFO & METADATA ─────────────────────────────────865# ══════════════════════════════════════════════════════════════════════866def file_info(uf, img: Image.Image) -> dict:867 data = uf.getvalue()868 sha, md5 = hash_bytes(data)869 return {870 "Filename" : uf.name,871 "File size" : f"{len(data)/1024:.1f} KB",872 "Format" : img.format or "Unknown",873 "Mode" : img.mode,874 "Resolution" : f"{img.width} × {img.height} px",875 "Megapixels" : f"{img.width*img.height/1e6:.2f} MP",876 "SHA-256" : sha,877 "MD5" : md5,878 }879 880 881# ══════════════════════════════════════════════════════════════════════882# ── MODULE 18 · COMPOSITE SCORE + RADAR CHART ────────────────────────883# ══════════════════════════════════════════════════════════════════════884def composite_score(ela_prob, clone_pairs, dct_score,885 noise_sigma, edge_density, splice_score,886 steg_susp, gan_cb, face_seam):887 w = {888 "ELA": (0.25, min(ela_prob, 100)),889 "Clone": (0.15, min(clone_pairs*2.5,100)),890 "DCT": (0.12, min(dct_score*1.5, 100)),891 "Noise": (0.08, min(noise_sigma*2, 100)),892 "Splice": (0.15, min(splice_score, 100)),893 "Steg": (0.10, min(steg_susp, 100)),894 "GAN": (0.08, min(gan_cb, 100)),895 "FaceSeam": (0.07, min(face_seam, 100)),896 }897 score = sum(wt*val for wt,val in w.values())898 return min(100.0, score), {k: v[1] for k,v in w.items()}899 900def verdict_from_score(s):901 if s < 15: return "AUTHENTIC", "#22c55e"902 elif s < 35: return "LIKELY AUTHENTIC", "#84cc16"903 elif s < 55: return "INCONCLUSIVE", "#facc15"904 elif s < 75: return "SUSPICIOUS", "#f97316"905 else: return "LIKELY MANIPULATED", "#ef4444"906 907def radar_chart(scores: dict, label: str) -> go.Figure:908 cats = list(scores.keys())909 vals = [scores[k] for k in cats]910 vals_closed = vals + [vals[0]]911 cats_closed = cats + [cats[0]]912 fig = go.Figure()913 fig.add_trace(go.Scatterpolar(914 r=vals_closed, theta=cats_closed,915 fill="toself",916 fillcolor="rgba(0,255,156,0.15)",917 line=dict(color="#00ff9c", width=2),918 name=label,919 ))920 fig.update_layout(921 polar=dict(922 radialaxis=dict(visible=True, range=[0,100],923 tickfont=dict(color="#9affd7",size=9),924 gridcolor="rgba(0,255,156,0.2)"),925 angularaxis=dict(tickfont=dict(color="#00ff9c",size=10),926 gridcolor="rgba(0,255,156,0.13)"),927 bgcolor="#000",928 ),929 paper_bgcolor="#000",930 plot_bgcolor="#000",931 showlegend=False,932 margin=dict(l=30,r=30,t=40,b=30),933 title=dict(text=label,font=dict(color="#00ff9c",size=13)),934 )935 return fig936 937 938# ══════════════════════════════════════════════════════════════════════939# ── MODULE 19 · CHAIN-OF-CUSTODY LOG ─────────────────────────────────940# ══════════════════════════════════════════════════════════════════════941class CustodyLog:942 def __init__(self):943 self.entries = []944 945 def add(self, action: str, detail: str = ""):946 self.entries.append({947 "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f UTC"),948 "action": action,949 "detail": detail,950 })951 952 def to_text(self) -> str:953 lines = ["╔══ CHAIN OF CUSTODY LOG ══════════════════════════════════╗",954 "║ Psychomods Cyber Forensic Lab v3.0 ║",955 "╚══════════════════════════════════════════════════════════╝",""]956 for e in self.entries:957 lines.append(f"[{e['timestamp']}] {e['action']}")958 if e["detail"]:959 lines.append(f" ↳ {e['detail']}")960 return "\n".join(lines)961 962 def to_json(self) -> str:963 return json.dumps(self.entries, indent=2)964 965 966# ══════════════════════════════════════════════════════════════════════967# ── MODULE 20 · PDF REPORT ────────────────────────────────────────────968# ══════════════════════════════════════════════════════════════════════969def build_pdf(label_a, label_b, info_a, info_b,970 stats_a, stats_b, anomalies_a, anomalies_b,971 custody_log: str) -> io.BytesIO:972 buf = io.BytesIO()973 doc = SimpleDocTemplate(buf, pagesize=A4,974 leftMargin=18*mm, rightMargin=18*mm,975 topMargin=18*mm, bottomMargin=18*mm)976 G = rl_colors.HexColor("#007744")977 BK = rl_colors.HexColor("#111")978 styles = getSampleStyleSheet()979 T = ParagraphStyle("T", fontName="Helvetica-Bold", fontSize=20, textColor=G, alignment=1, spaceAfter=4)980 S = ParagraphStyle("S", fontName="Helvetica", fontSize=10, textColor=rl_colors.grey, alignment=1, spaceAfter=12)981 H2 = ParagraphStyle("H2", fontName="Helvetica-Bold", fontSize=13, textColor=G, spaceBefore=10, spaceAfter=4)982 BD = ParagraphStyle("BD", fontName="Helvetica", fontSize=9, textColor=BK, spaceAfter=3, leading=13)983 WN = ParagraphStyle("WN", fontName="Helvetica-Oblique",fontSize=9, textColor=rl_colors.HexColor("#cc5500"), spaceAfter=3)984 OK = ParagraphStyle("OK", fontName="Helvetica-Oblique",fontSize=9, textColor=rl_colors.HexColor("#007744"), spaceAfter=3)985 986 story = []987 story.append(Paragraph("🧠 PSYCHOMODS CYBER FORENSIC LAB", T))988 story.append(Paragraph("Advanced Image Manipulation Detection Report — v3.0", S))989 story.append(Paragraph(f"Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}", S))990 story.append(HRFlowable(width="100%", thickness=1, color=G))991 story.append(Spacer(1,8))992 993 def img_section(label, info, stats, anomalies):994 story.append(Paragraph(f"► {label}", H2))995 story.append(HRFlowable(width="60%", thickness=.5, color=G))996 story.append(Spacer(1,4))997 # Info table (skip if empty — single-image mode)998 if info:999 td = [[Paragraph(f"<b>{k}</b>",BD), Paragraph(v,BD)] for k,v in info.items()]1000 t = Table(td, colWidths=[55*mm,115*mm])1001 t.setStyle(TableStyle([1002 ("BACKGROUND",(0,0),(0,-1),rl_colors.HexColor("#e8fff5")),1003 ("GRID",(0,0),(-1,-1),.3,rl_colors.HexColor("#aaddcc")),1004 ("ROWBACKGROUNDS",(0,0),(-1,-1),[rl_colors.HexColor("#f0fff8"),rl_colors.white]),1005 ])); story.append(t); story.append(Spacer(1,8))1006 # Stats table (skip if empty)1007 if stats:1008 story.append(Paragraph("Forensic Metrics", H2))1009 sd = [[Paragraph(f"<b>{k}</b>",BD), Paragraph(str(v),BD)] for k,v in stats.items()]1010 t2 = Table(sd, colWidths=[75*mm,95*mm])1011 t2.setStyle(TableStyle([1012 ("BACKGROUND",(0,0),(0,-1),rl_colors.HexColor("#e8fff5")),1013 ("GRID",(0,0),(-1,-1),.3,rl_colors.HexColor("#aaddcc")),1014 ("ROWBACKGROUNDS",(0,0),(-1,-1),[rl_colors.HexColor("#f0fff8"),rl_colors.white]),1015 ])); story.append(t2); story.append(Spacer(1,8))1016 # Anomalies1017 if anomalies:1018 story.append(Paragraph("⚠️ Anomalies Detected", H2))1019 for a in anomalies:1020 story.append(Paragraph(f"• {a}", WN))1021 else:1022 story.append(Paragraph("✓ No anomalies detected", OK))1023 story.append(Spacer(1,12))1024 1025 img_section(label_a, info_a, stats_a, anomalies_a)1026 img_section(label_b, info_b, stats_b, anomalies_b)1027 1028 # Custody log (truncated)1029 story.append(HRFlowable(width="100%",thickness=1,color=G))1030 story.append(Paragraph("Chain of Custody Log", H2))1031 for line in custody_log.split("\n")[:40]:1032 story.append(Paragraph(line or " ", BD))1033 1034 story.append(Spacer(1,8))1035 story.append(HRFlowable(width="100%",thickness=.5,color=G))1036 story.append(Paragraph(1037 "DISCLAIMER: Algorithmic results only. Validate with a certified examiner before legal use.",1038 ParagraphStyle("disc",fontName="Helvetica-Oblique",fontSize=8,textColor=rl_colors.grey)))1039 story.append(Paragraph(1040 "Psychomods Cyber Security Tools | https://psychomods.infy.uk",1041 ParagraphStyle("foot",fontName="Helvetica",fontSize=8,textColor=G,alignment=1)))1042 1043 doc.build(story); buf.seek(0)1044 return buf1045 1046 1047# ══════════════════════════════════════════════════════════════════════1048# ── MODULE 21 · ZIP EVIDENCE PACKAGE ─────────────────────────────────1049# ══════════════════════════════════════════════════════════════════════1050def build_zip(pdf_buf, txt_report, custody_json, images: dict) -> io.BytesIO:1051 zb = io.BytesIO()1052 with zipfile.ZipFile(zb, "w", zipfile.ZIP_DEFLATED) as zf:1053 zf.writestr("forensic_report.pdf", pdf_buf.getvalue())1054 zf.writestr("forensic_report.txt", txt_report)1055 zf.writestr("chain_of_custody.json",custody_json)1056 for name, pil_img in images.items():1057 ib = io.BytesIO()1058 pil_img.save(ib,"PNG"); ib.seek(0)1059 zf.writestr(f"analysis_images/{name}.png", ib.getvalue())1060 zb.seek(0); return zb1061 1062 1063# ══════════════════════════════════════════════════════════════════════1064# SIDEBAR1065# ══════════════════════════════════════════════════════════════════════1066with st.sidebar:1067 st.markdown("### ⚙️ Settings")1068 mode = st.radio("Analysis Mode",1069 ["🔁 Two-Image Compare","📷 Single Image","📦 Batch (up to 10)"],1070 index=0)1071 st.markdown("---")1072 ela_q = st.slider("ELA JPEG Quality", 60, 98, 92)1073 ela_s = st.slider("ELA Amplification", 5, 40, 20)1074 ela_t = st.slider("Region Threshold", 5, 80, 20)1075 min_a = st.slider("Min Region Area px", 50,2000,300,50)1076 diff_amp= st.slider("Pixel Diff Amplify", 1, 20, 5)1077 st.markdown("---")1078 st.markdown("""1079**v3.0 Modules**10801. ELA + Heatmap10812. Region Detection10823. Clone/Copy-Move10834. Noise Residual10845. Edge Analysis10856. FFT Spectrum10867. DCT Block Analysis10878. LBP Texture10889. JPEG Ghost108910. Luminance Gradient109011. Histogram109112. **Steganography (LSB)**109213. **Metadata Timeline**109314. **Splicing Boundary**109415. **GAN/AI Artifact**109516. **Face Analysis**109617. **Pixel Diff**109718. **Composite + Radar**109819. **Chain of Custody**109920. **ZIP Evidence Export**1100 1101[🌐 psychomods.infy.uk](https://psychomods.infy.uk)1102""")1103 1104 1105# ══════════════════════════════════════════════════════════════════════1106# HELPER: run full pipeline on a single image1107# ══════════════════════════════════════════════════════════════════════1108def run_pipeline(img: Image.Image, label: str, uf=None,1109 log: CustodyLog = None, prog=None, base=0, span=100):1110 1111 def tick(pct, msg):1112 if prog: prog.progress(base + int(pct*span/100), msg)1113 if log: log.add(msg, label)1114 1115 results = {}1116 imgs = {}1117 1118 tick(2, "ELA…")1119 e = ela(img, ela_q, ela_s)1120 em = ela_heatmap(e)1121 er, n_regions = ela_region_detect(img, e, ela_t, min_a)1122 mean,std,p95,prob,verdict,color = ela_stats(e)1123 results["ela"] = dict(mean=mean,std=std,p95=p95,prob=prob,1124 verdict=verdict,color=color,n_regions=n_regions)1125 imgs["ela"] = e; imgs["ela_heat"] = em; imgs["ela_regions"] = er1126 1127 tick(12, "Clone detection…")1128 cl_img, cp = clone_detect(img)1129 results["clone"] = dict(pairs=cp)1130 imgs["clone"] = cl_img1131 1132 tick(22, "Noise residual…")1133 nr_img, sig = noise_residual(img)1134 results["noise"] = dict(sigma=sig)1135 imgs["noise"] = nr_img1136 1137 tick(30, "Edge analysis…")1138 ed_img, dens = edge_analysis(img)1139 results["edge"] = dict(density=dens)1140 imgs["edge"] = ed_img1141 1142 tick(36, "FFT…")1143 imgs["fft"] = fft_analysis(img)1144 1145 tick(42, "DCT…")1146 dct_img, dct_s = dct_analysis(img)1147 results["dct"] = dict(score=dct_s)1148 imgs["dct"] = dct_img1149 1150 tick(48, "LBP texture…")1151 imgs["lbp"] = lbp_analysis(img)1152 1153 tick(52, "JPEG ghost…")1154 imgs["jpeg_ghost"] = jpeg_ghost(img)1155 1156 tick(56, "Luminance gradient…")1157 imgs["lum_grad"] = luminance_gradient(img)1158 1159 tick(60, "Histogram…")1160 imgs["histogram"] = histogram_plot(img)1161 1162 tick(64, "Steganography…")1163 steg_lsb = steg_lsb_planes(img)1164 steg_ent = steg_entropy_map(img)1165 steg_bp = steg_bit_planes(img)1166 chi2, steg_susp, steg_flag = steg_chi_square(img)1167 results["steg"] = dict(chi2=chi2,susp=steg_susp,flag=steg_flag)1168 imgs["steg_lsb"] = steg_lsb; imgs["steg_entropy"] = steg_ent1169 imgs["steg_bitplanes"] = steg_bp1170 1171 tick(70, "Metadata timeline…")1172 exif_data = extract_exif_full(img)1173 events, anomalies, meta_risk = metadata_timeline(exif_data)1174 results["meta"] = dict(events=events,anomalies=anomalies,risk=meta_risk)1175 1176 tick(76, "Splicing boundary…")1177 spl_img, spl_s = splicing_boundary(img)1178 results["splice"] = dict(score=spl_s)1179 imgs["splicing"] = spl_img1180 1181 tick(82, "GAN/AI artifact detection…")1182 gan_fft_img, cb_score, cb_peaks = gan_checkerboard(img)1183 gan_tex_img, tex_score = gan_texture_variance(img)1184 gan_col_img, col_score, ch_corr = gan_color_inconsistency(img)1185 gan_pow_img, pow_score = gan_frequency_fingerprint(img)1186 gan_score = (cb_score*0.35 + tex_score*0.25 +1187 col_score*0.20 + (pow_score or 0)*0.20)1188 results["gan"] = dict(cb=cb_score,texture=tex_score,1189 color=col_score,power=pow_score or 0,1190 score=gan_score,corr=ch_corr)1191 imgs["gan_fft"] = gan_fft_img; imgs["gan_tex"] = gan_tex_img1192 imgs["gan_col"] = gan_col_img1193 if gan_pow_img: imgs["gan_pow"] = gan_pow_img1194 1195 tick(90, "Face analysis…")1196 face_img, face_res = face_analysis_cv(img)1197 seam_img, seam_score = face_blending_boundary(img)1198 results["face"] = dict(data=face_res, seam=seam_score)1199 imgs["face"] = face_img; imgs["face_seam"] = seam_img1200 