""" Liveness Detection Module Detects if the face is from a live person or a photo/video spoof """ import cv2 import numpy as np import logging from typing import Tuple, Optional logger = logging.getLogger(__name__) class LivenessDetector: """ Liveness detection using multiple techniques: 1. Texture analysis (LBP) 2. Color space analysis 3. Frequency domain analysis 4. Eye blink detection (optional) """ def __init__(self): """Initialize liveness detector""" self.lbp_threshold = 0.5 self.color_threshold = 0.4 self.frequency_threshold = 0.3 def detect_liveness(self, image: np.ndarray, face: Tuple[int, int, int, int]) -> bool: """ Detect if face is from a live person Args: image: Input image (BGR format) face: Face bounding box (x, y, w, h) Returns: True if live, False if spoof detected """ try: x, y, w, h = face face_roi = image[y:y+h, x:x+w] if face_roi.size == 0: return False # Run multiple liveness checks scores = [] # 1. Texture analysis texture_score = self._analyze_texture(face_roi) scores.append(texture_score) # 2. Color space analysis color_score = self._analyze_color_space(face_roi) scores.append(color_score) # 3. Frequency domain analysis frequency_score = self._analyze_frequency(face_roi) scores.append(frequency_score) # 4. Moiré pattern detection moire_score = self._detect_moire_pattern(face_roi) scores.append(moire_score) # Combine scores (weighted average) weights = [0.3, 0.25, 0.25, 0.2] final_score = sum(s * w for s, w in zip(scores, weights)) # Threshold for liveness is_live = final_score > 0.5 logger.debug(f"Liveness scores: texture={texture_score:.3f}, color={color_score:.3f}, " f"frequency={frequency_score:.3f}, moire={moire_score:.3f}, " f"final={final_score:.3f}, live={is_live}") return is_live except Exception as e: logger.error(f"Liveness detection error: {e}") # Default to True to avoid false rejections return True def _analyze_texture(self, face_roi: np.ndarray) -> float: """ Analyze texture using Local Binary Patterns Real faces have more complex texture than printed photos """ try: gray = cv2.cvtColor(face_roi, cv2.COLOR_BGR2GRAY) # Compute LBP lbp = self._compute_lbp(gray) # Calculate histogram hist, _ = np.histogram(lbp.ravel(), bins=256, range=(0, 256)) hist = hist.astype("float") hist /= (hist.sum() + 1e-7) # Calculate entropy (higher entropy = more texture = more likely real) entropy = -np.sum(hist * np.log2(hist + 1e-7)) # Normalize entropy to [0, 1] max_entropy = np.log2(256) normalized_entropy = entropy / max_entropy return normalized_entropy except Exception as e: logger.error(f"Texture analysis error: {e}") return 0.5 def _compute_lbp(self, image: np.ndarray, radius: int = 1, n_points: int = 8) -> np.ndarray: """Compute Local Binary Pattern""" h, w = image.shape lbp = np.zeros((h, w), dtype=np.uint8) for i in range(radius, h - radius): for j in range(radius, w - radius): center = image[i, j] code = 0 for k in range(n_points): angle = 2 * np.pi * k / n_points x = int(round(i + radius * np.cos(angle))) y = int(round(j + radius * np.sin(angle))) if 0 <= x < h and 0 <= y < w: if image[x, y] >= center: code |= (1 << k) lbp[i, j] = code return lbp def _analyze_color_space(self, face_roi: np.ndarray) -> float: """ Analyze color distribution Real faces have specific color characteristics in different color spaces """ try: # Convert to different color spaces hsv = cv2.cvtColor(face_roi, cv2.COLOR_BGR2HSV) ycrcb = cv2.cvtColor(face_roi, cv2.COLOR_BGR2YCrCb) # Analyze skin color in YCrCb space # Typical skin color ranges: Cr=[133-173], Cb=[77-127] cr = ycrcb[:, :, 1] cb = ycrcb[:, :, 2] # Calculate percentage of pixels in skin color range skin_mask = ((cr >= 133) & (cr <= 173) & (cb >= 77) & (cb <= 127)) skin_percentage = np.sum(skin_mask) / skin_mask.size # Analyze color variance color_std = np.std(face_roi, axis=(0, 1)) color_variance = np.mean(color_std) / 255.0 # Combine metrics score = (skin_percentage * 0.6 + color_variance * 0.4) return min(1.0, score) except Exception as e: logger.error(f"Color space analysis error: {e}") return 0.5 def _analyze_frequency(self, face_roi: np.ndarray) -> float: """ Analyze frequency domain Printed photos have different frequency characteristics than real faces """ try: gray = cv2.cvtColor(face_roi, cv2.COLOR_BGR2GRAY) # Apply FFT f_transform = np.fft.fft2(gray) f_shift = np.fft.fftshift(f_transform) magnitude = np.abs(f_shift) # Analyze high frequency components h, w = magnitude.shape center_h, center_w = h // 2, w // 2 # Define high frequency region (outer 30%) mask = np.zeros((h, w), dtype=np.uint8) cv2.circle(mask, (center_w, center_h), int(min(h, w) * 0.35), 1, -1) cv2.circle(mask, (center_w, center_h), int(min(h, w) * 0.15), 0, -1) # Calculate high frequency energy high_freq_energy = np.sum(magnitude * mask) total_energy = np.sum(magnitude) high_freq_ratio = high_freq_energy / (total_energy + 1e-7) # Real faces typically have more high frequency content score = min(1.0, high_freq_ratio * 10) return score except Exception as e: logger.error(f"Frequency analysis error: {e}") return 0.5 def _detect_moire_pattern(self, face_roi: np.ndarray) -> float: """ Detect Moiré patterns (common in photos of screens/photos) """ try: gray = cv2.cvtColor(face_roi, cv2.COLOR_BGR2GRAY) # Apply bandpass filter to detect periodic patterns # Moiré patterns appear as regular wave-like patterns # Compute gradient grad_x = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3) grad_y = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3) # Compute gradient magnitude grad_mag = np.sqrt(grad_x**2 + grad_y**2) # Analyze gradient variance # Moiré patterns have high periodic variance grad_std = np.std(grad_mag) grad_mean = np.mean(grad_mag) if grad_mean > 0: coefficient_of_variation = grad_std / grad_mean else: coefficient_of_variation = 0 # Lower CV suggests less periodic patterns (more likely real) # Normalize and invert (higher score = more likely real) score = max(0.0, 1.0 - min(1.0, coefficient_of_variation / 2.0)) return score except Exception as e: logger.error(f"Moiré pattern detection error: {e}") return 0.5 def detect_eye_blink(self, frames: list) -> bool: """ Detect eye blink across multiple frames This requires video input (multiple frames) Args: frames: List of consecutive frames Returns: True if blink detected, False otherwise """ try: if len(frames) < 3: logger.warning("Not enough frames for blink detection") return False # Load eye cascade classifier eye_cascade = cv2.CascadeClassifier( cv2.data.haarcascades + 'haarcascade_eye.xml' ) eye_states = [] for frame in frames: gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) eyes = eye_cascade.detectMultiScale(gray, 1.3, 5) # If eyes detected, mark as open (1), else closed (0) eye_states.append(1 if len(eyes) >= 2 else 0) # Check for blink pattern: open -> closed -> open # Pattern: [1, 1, 0, 1, 1] or similar for i in range(len(eye_states) - 2): if eye_states[i] == 1 and eye_states[i+1] == 0 and eye_states[i+2] == 1: return True return False except Exception as e: logger.error(f"Eye blink detection error: {e}") return False def get_liveness_score_detailed(self, image: np.ndarray, face: Tuple[int, int, int, int]) -> dict: """ Get detailed liveness scores for debugging Args: image: Input image face: Face bounding box Returns: Dictionary with detailed scores """ try: x, y, w, h = face face_roi = image[y:y+h, x:x+w] if face_roi.size == 0: return {"error": "Empty face ROI"} texture_score = self._analyze_texture(face_roi) color_score = self._analyze_color_space(face_roi) frequency_score = self._analyze_frequency(face_roi) moire_score = self._detect_moire_pattern(face_roi) weights = [0.3, 0.25, 0.25, 0.2] scores = [texture_score, color_score, frequency_score, moire_score] final_score = sum(s * w for s, w in zip(scores, weights)) return { "texture_score": round(texture_score, 3), "color_score": round(color_score, 3), "frequency_score": round(frequency_score, 3), "moire_score": round(moire_score, 3), "final_score": round(final_score, 3), "is_live": final_score > 0.5 } except Exception as e: logger.error(f"Detailed liveness score error: {e}") return {"error": str(e)}