Spaces:
Sleeping
Sleeping
File size: 14,637 Bytes
7104b2c 34ad4eb 7104b2c 34ad4eb 7104b2c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 | import numpy as np
import cv2
import torch
import os
import logging
from PIL import Image, ImageFilter, ImageStat
from typing import Tuple, Optional
# Configure local logger for audit fixes
logger = logging.getLogger("ForensicAuditor")
class ForensicAuditorFixes:
"""
Consolidated forensic processing library.
v3: Improved face detection, better ELA, enhanced frequency analysis,
noise consistency checks, and chromatic aberration detection.
"""
# ==========================================
# SECTION 1: Input Sanitization
# ==========================================
@staticmethod
def sanitize_image(image: Image.Image) -> Image.Image:
"""
Handles CMYK, RGBA, Grayscale, and P-mode images securely.
Prevents black-alpha artifacts on RGBA composites.
"""
if image.mode == 'RGBA':
background = Image.new('RGB', image.size, (255, 255, 255))
background.paste(image, mask=image.split()[3])
image = background
elif image.mode == 'P':
image = image.convert('RGBA')
background = Image.new('RGB', image.size, (255, 255, 255))
background.paste(image, mask=image.split()[3])
image = background
elif image.mode != 'RGB':
image = image.convert('RGB')
w, h = image.size
if w < 64 or h < 64:
raise ValueError(f"IMAGE_TOO_SMALL: {w}x{h} — minimum 64px for forensic analysis.")
if w > 8000 or h > 8000:
image.thumbnail((8000, 8000), Image.Resampling.LANCZOS)
return image
@staticmethod
def sanitize_filename(filename: str) -> str:
if not filename:
return "scrubbed_asset.unknown"
clean = "".join([c for c in filename if c.isalnum() or c in (' ', '.', '_', '-')]).rstrip()
return clean if clean else "safe_asset_rename.unknown"
# ==========================================
# SECTION 2: Face-Aware Region Extraction
# ==========================================
@staticmethod
def extract_face_region(image: Image.Image) -> Tuple[Image.Image, bool]:
"""
Detects faces using OpenCV DNN face detector (much better than Haar).
Falls back to Haar if DNN model not available.
Returns (cropped_face_or_full_image, face_found).
"""
arr = np.array(image.convert("RGB"))
h, w = arr.shape[:2]
# Try DNN face detector first (much more accurate)
face_box = ForensicAuditorFixes._detect_face_dnn(arr)
if face_box is None:
# Fallback to Haar cascade
face_box = ForensicAuditorFixes._detect_face_haar(arr)
if face_box is None:
return image, False
x, y, fw, fh = face_box
# Add 40% padding around face for context
pad_x = int(fw * 0.40)
pad_y = int(fh * 0.40)
x1 = max(0, x - pad_x)
y1 = max(0, y - pad_y)
x2 = min(w, x + fw + pad_x)
y2 = min(h, y + fh + pad_y)
face_crop = arr[y1:y2, x1:x2]
return Image.fromarray(face_crop), True
@staticmethod
def _detect_face_dnn(arr: np.ndarray) -> Optional[Tuple[int, int, int, int]]:
"""Use OpenCV DNN face detector if available."""
try:
h, w = arr.shape[:2]
# Use OpenCV's built-in DNN detector if available
prototxt = os.path.join(cv2.data.haarcascades, "..", "dnn", "face_detector", "deploy.prototxt")
caffemodel = os.path.join(cv2.data.haarcascades, "..", "dnn", "face_detector", "res10_300x300_ssd_iter_140000.caffemodel")
if not os.path.exists(prototxt) or not os.path.exists(caffemodel):
logger.debug("DNN face detector: model files not found — using Haar cascade fallback.")
return None
net = cv2.dnn.readNetFromCaffe(prototxt, caffemodel)
blob = cv2.dnn.blobFromImage(cv2.cvtColor(arr, cv2.COLOR_RGB2BGR), 1.0, (300, 300), [104.0, 177.0, 123.0])
net.setInput(blob)
detections = net.forward()
best_conf = 0.0
best_box = None
for i in range(detections.shape[2]):
confidence = detections[0, 0, i, 2]
if confidence > 0.5 and confidence > best_conf:
best_conf = confidence
box = detections[0, 0, i, 3:7] * np.array([w, h, w, h])
x1, y1, x2, y2 = box.astype(int)
best_box = (x1, y1, x2 - x1, y2 - y1)
return best_box
except Exception:
return None
@staticmethod
def _detect_face_haar(arr: np.ndarray) -> Optional[Tuple[int, int, int, int]]:
"""Legacy Haar cascade fallback."""
gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY)
cascade_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
face_cascade = cv2.CascadeClassifier(cascade_path)
faces = face_cascade.detectMultiScale(
gray,
scaleFactor=1.1,
minNeighbors=4,
minSize=(48, 48),
flags=cv2.CASCADE_SCALE_IMAGE
)
if len(faces) == 0:
return None
return max(faces, key=lambda f: f[2] * f[3])
# ==========================================
# SECTION 3: Neural Stability
# ==========================================
@staticmethod
def stable_softmax(logits: torch.Tensor, temperature: float = 1.0) -> torch.Tensor:
logits_f32 = logits.to(torch.float32) / temperature
return torch.nn.functional.softmax(logits_f32, dim=-1)
# ==========================================
# SECTION 4: Multi-Pass ELA (Calibrated v3)
# ==========================================
@staticmethod
def compute_robust_ela(image: Image.Image, quality: int = 90) -> Tuple[Image.Image, float]:
"""
Improved ELA with better calibration and PNG-aware processing.
"""
rgb_arr = np.array(image)[:, :, ::-1].copy()
diff_maps = []
pass_means = []
for q in (75, 90, 95):
_, encimg = cv2.imencode(".jpg", rgb_arr, [int(cv2.IMWRITE_JPEG_QUALITY), q])
decimg = cv2.imdecode(encimg, 1)
diff = np.abs(rgb_arr.astype(np.float32) - decimg.astype(np.float32))
pass_means.append(float(np.mean(diff)))
diff_maps.append(diff)
# Cross-pass variance — AI images tend to have more uniform error
pass_variance = float(np.std(pass_means))
variance_score = max(0.0, min(1.0, 1.0 - (pass_variance / 4.0)))
# Magnitude at q=90 — recalibrated so typical images land mid-range
mean_ela = pass_means[1]
# Typical mean_ela: real JPEG ~3-8, AI PNG ~1-3, heavily edited ~10+
magnitude_score = max(0.0, min(1.0, 1.0 - ((mean_ela - 0.5) / 6.0)))
# Blend
ela_score = 0.4 * variance_score + 0.6 * magnitude_score
ela_score = max(0.0, min(1.0, ela_score))
# Visualization
best_map = diff_maps[0][:, :, ::-1]
max_val = float(np.max(best_map))
if max_val > 0:
best_map = best_map / max_val * 255.0
ela_image = Image.fromarray(best_map.astype(np.uint8))
return ela_image, float(ela_score)
# ==========================================
# SECTION 5: Hanning-Windowed FFT (Enhanced)
# ==========================================
@staticmethod
def compute_robust_fft(image: Image.Image) -> Tuple[Image.Image, float, np.ndarray]:
"""
Frequency domain analysis with GAN artifact detection.
"""
gray = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2GRAY).astype(np.float32)
h, w = gray.shape
# Hanning Window
win_y = np.hanning(h)
win_x = np.hanning(w)
hanning_2d = np.outer(win_y, win_x)
windowed = gray * hanning_2d
fshift = np.fft.fftshift(np.fft.fft2(windowed))
magnitude = 20 * np.log(np.abs(fshift) + 1e-8)
# High-frequency ring energy
cy, cx = h // 2, w // 2
r_inner = min(h, w) // 8
r_outer = min(h, w) // 3
y_idx, x_idx = np.ogrid[:h, :w]
dist_sq = (x_idx - cx) ** 2 + (y_idx - cy) ** 2
hf_mask = (dist_sq > r_inner ** 2) & (dist_sq <= r_outer ** 2)
hf_energy = np.mean(magnitude[hf_mask])
ring_score = min(max(hf_energy / 160.0, 0.0), 1.0)
# Periodic peak detection
flat = magnitude[hf_mask].flatten()
if len(flat) > 0 and np.std(flat) > 0:
spike_threshold = np.mean(flat) + 2.5 * np.std(flat)
spike_fraction = np.sum(flat > spike_threshold) / (len(flat) + 1e-8)
peak_score = min(spike_fraction * 15.0, 1.0)
else:
peak_score = 0.0
fft_score = 0.35 * ring_score + 0.65 * peak_score
fft_score = max(0.0, min(1.0, fft_score))
# Visualization
mag_norm = cv2.normalize(magnitude, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
fft_colored = cv2.applyColorMap(mag_norm, cv2.COLORMAP_INFERNO)
fft_image = Image.fromarray(cv2.cvtColor(fft_colored, cv2.COLOR_BGR2RGB))
return fft_image, float(fft_score), magnitude
# ==========================================
# SECTION 6: DCT Block Boundary Analysis
# ==========================================
@staticmethod
def compute_dct_block_score(image: Image.Image) -> float:
"""
Detects JPEG DCT block boundary artifacts.
"""
gray = cv2.cvtColor(np.array(image.convert("RGB")), cv2.COLOR_RGB2GRAY)
gray_f = gray.astype(np.float32)
# Use Laplacian for sharper edge detection
grad = cv2.Laplacian(gray_f, cv2.CV_32F)
gradient_mag = np.abs(grad)
h, w = gray.shape
# Sample at 8x8 boundaries
boundary_rows = [r for r in range(0, h, 8) if 0 < r < h]
boundary_cols = [c for c in range(0, w, 8) if 0 < c < w]
boundary_grad = []
for r in boundary_rows:
boundary_grad.append(np.mean(gradient_mag[r, :]))
for c in boundary_cols:
boundary_grad.append(np.mean(gradient_mag[:, c]))
# Sample in block interiors
interior_rows = [r + 4 for r in range(0, h - 4, 8)]
interior_cols = [c + 4 for c in range(0, w - 4, 8)]
interior_grad = []
for r in interior_rows:
if r < h:
interior_grad.append(np.mean(gradient_mag[r, :]))
for c in interior_cols:
if c < w:
interior_grad.append(np.mean(gradient_mag[:, c]))
if not boundary_grad or not interior_grad:
return 0.5
boundary_mean = np.mean(boundary_grad)
interior_mean = np.mean(interior_grad)
ratio = boundary_mean / (interior_mean + 1e-8)
score = min(max((ratio - 0.8) / 2.0, 0.0), 1.0)
return float(score)
# ==========================================
# SECTION 7: Noise Consistency Analysis (NEW)
# ==========================================
@staticmethod
def compute_noise_consistency(image: Image.Image) -> float:
"""
AI-generated images often have unnaturally uniform noise.
Real camera photos have photon noise that varies with brightness.
Returns score where high = likely synthetic.
"""
gray = cv2.cvtColor(np.array(image.convert("RGB")), cv2.COLOR_RGB2GRAY).astype(np.float32)
h, w = gray.shape
# Downsample large images to avoid texture dominating
if max(h, w) > 1024:
scale = 1024 / max(h, w)
gray = cv2.resize(gray, (int(w * scale), int(h * scale)))
# Denoise to isolate noise
denoised = cv2.medianBlur(gray.astype(np.uint8), 5).astype(np.float32)
noise = np.abs(gray - denoised)
# Divide into 4x4 grid and measure noise std in each cell
cells_h, cells_w = 4, 4
cell_h, cell_w = gray.shape[0] // cells_h, gray.shape[1] // cells_w
cell_stds = []
for i in range(cells_h):
for j in range(cells_w):
y1, y2 = i * cell_h, (i + 1) * cell_h
x1, x2 = j * cell_w, (j + 1) * cell_w
cell = noise[y1:y2, x1:x2]
if cell.size > 0:
cell_stds.append(np.std(cell))
if len(cell_stds) < 4:
return 0.5
# Real photos: noise std varies across image (lighting, focus, ISO variation)
# AI images: noise std is very uniform across image
mean_std = np.mean(cell_stds) + 1e-8
std_of_stds = np.std(cell_stds)
uniformity = std_of_stds / mean_std # CV of noise std across cells
# Real: uniformity > 0.35 (noise varies spatially)
# AI: uniformity < 0.15 (noise is unnaturally uniform)
score = 1.0 - ((uniformity - 0.10) / 0.30)
score = max(0.0, min(1.0, score))
return float(score)
# ==========================================
# SECTION 8: Edge Sharpness Analysis (NEW)
# ==========================================
@staticmethod
def compute_edge_sharpness(image: Image.Image) -> float:
"""
AI images often have unnaturally perfect edge transitions.
Real photos have softer, more variable edges due to lens optics.
Returns score where high = likely synthetic.
"""
gray = cv2.cvtColor(np.array(image.convert("RGB")), cv2.COLOR_RGB2GRAY)
# Sobel gradients
sobelx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)
sobely = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
grad_mag = np.sqrt(sobelx**2 + sobely**2)
# Find strong edges (top 10% gradient magnitude)
threshold = np.percentile(grad_mag, 90)
edge_mask = grad_mag > threshold
if np.sum(edge_mask) < 100:
return 0.5
# Measure how sharp edges are: AI has very narrow transition (high local max)
# Real photos have softer roll-off
edge_values = grad_mag[edge_mask]
# Coefficient of variation of edge gradients
# Real: more variation in edge sharpness (CV ~0.5-1.0)
# AI: very consistent edge sharpness (CV ~0.2-0.4)
mean_grad = np.mean(edge_values) + 1e-8
std_grad = np.std(edge_values)
cv_grad = std_grad / mean_grad
# Map: cv < 0.25 -> synthetic (score high), cv > 0.6 -> real (score low)
score = 1.0 - ((cv_grad - 0.25) / 0.35)
score = max(0.0, min(1.0, score))
return float(score)
|