"""Image feature extraction utilities using OpenCV and NumPy.""" import io from pathlib import Path import cv2 import numpy as np from PIL import Image from PIL.ExifTags import TAGS ALLOWED_IMAGE = {".jpg", ".jpeg", ".png", ".webp"} def validate_image_file(filename: str) -> str: """Validate image file extension. Returns extension or raises ValueError.""" ext = Path(filename).suffix.lower() if ext not in ALLOWED_IMAGE: raise ValueError( f"File type '{ext}' is not supported. Accepted: {', '.join(sorted(ALLOWED_IMAGE))}" ) return ext def load_image(image_bytes: bytes) -> np.ndarray: """Load image bytes into a numpy array (BGR format for OpenCV).""" nparr = np.frombuffer(image_bytes, np.uint8) img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) if img is None: raise ValueError("Could not decode image file") return img def extract_features(image: np.ndarray) -> dict: """Extract image features for deepfake analysis.""" h, w = image.shape[:2] # --- Frequency domain analysis (FFT) --- gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) fft = np.fft.fft2(gray) fft_shift = np.fft.fftshift(fft) magnitude = np.abs(fft_shift) h_half, w_half = h // 2, w // 2 center_size = min(h, w) // 8 low_freq = magnitude[ h_half - center_size : h_half + center_size, w_half - center_size : w_half + center_size, ] total_energy = float(np.sum(magnitude)) low_energy = float(np.sum(low_freq)) high_freq_ratio = 1.0 - (low_energy / total_energy) if total_energy > 0 else 0.0 log_magnitude = np.log1p(magnitude) freq_std = float(np.std(log_magnitude)) has_gan_artifacts = freq_std < 2.0 and high_freq_ratio > 0.7 if has_gan_artifacts: frequency_anomalies = "high_frequency_grid_pattern" elif high_freq_ratio > 0.8: frequency_anomalies = "unusual_high_freq_energy" else: frequency_anomalies = "normal_distribution" # --- Face detection --- face_cascade = cv2.CascadeClassifier( cv2.data.haarcascades + "haarcascade_frontalface_default.xml" ) gray_small = cv2.resize(gray, (min(w, 800), min(h, 800))) faces = face_cascade.detectMultiScale(gray_small, scaleFactor=1.1, minNeighbors=5) face_detected = len(faces) > 0 # --- Facial symmetry --- facial_symmetry = 0.0 if face_detected: fx, fy, fw, fh = faces[0] face_roi = gray_small[fy : fy + fh, fx : fx + fw] face_roi = cv2.resize(face_roi, (100, 100)) left_half = face_roi[:, :50] right_half = cv2.flip(face_roi[:, 50:], 1) diff = np.abs(left_half.astype(float) - right_half.astype(float)) symmetry = 1.0 - (np.mean(diff) / 255.0) facial_symmetry = round(float(symmetry), 2) # --- Lighting consistency --- h2, w2 = h // 2, w // 2 quadrants = [ gray[:h2, :w2], gray[:h2, w2:], gray[h2:, :w2], gray[h2:, w2:], ] quad_means = [float(np.mean(q)) for q in quadrants] lighting_consistency = round(1.0 - (np.std(quad_means) / 128.0), 2) lighting_consistency = max(0.0, min(1.0, lighting_consistency)) # --- Skin texture smoothness (GAN face detector) --- # StyleGAN faces lack natural skin texture (pores, fine lines). # Measure via local variance in the face region — too smooth = suspicious. skin_smoothness = 0.0 if face_detected: fx, fy, fw, fh = faces[0] # Focus on cheek/forehead area (center of face) margin_x = int(fw * 0.2) margin_y = int(fh * 0.2) face_region = gray_small[ fy + margin_y : fy + fh - margin_y, fx + margin_x : fx + fw - margin_x, ] if face_region.size > 0: # Local variance using Laplacian (measures texture detail) laplacian = cv2.Laplacian(face_region, cv2.CV_64F) local_var = float(np.var(laplacian)) # Real skin has variance ~500-2000, GAN skin is often ~50-300 # Normalize to 0-1 scale where 1.0 = suspiciously smooth skin_smoothness = round(max(0.0, min(1.0, 1.0 - (local_var - 50) / 1500)), 2) # --- Metadata integrity --- metadata_integrity = check_metadata_integrity(image) return { "gan_artifacts": has_gan_artifacts, "frequency_anomalies": frequency_anomalies, "facial_symmetry": facial_symmetry, "lighting_consistency": lighting_consistency, "metadata_integrity": metadata_integrity, "face_detected": face_detected, "skin_smoothness": skin_smoothness, "image_dimensions": f"{w}x{h}", } def check_metadata_integrity(image_bytes_or_pil_source) -> str: """Check EXIF metadata status. AI-generated images typically have stripped or minimal EXIF data. Real camera photos usually have rich EXIF data (camera model, settings, etc). """ try: if isinstance(image_bytes_or_pil_source, bytes): img = Image.open(io.BytesIO(image_bytes_or_pil_source)) else: return "unknown" except Exception: return "unknown" exif_data = img._getexif() if exif_data is None: return "exif_stripped" camera_tags = { 271: "Make", 272: "Model", 33434: "ExposureTime", 33437: "FNumber", 37500: "MakerNote", 36867: "DateTimeOriginal", } found_camera_tags = sum(1 for tag_id in camera_tags if tag_id in exif_data) if found_camera_tags >= 3: return "clean" elif found_camera_tags >= 1: return "partial" else: return "exif_stripped"