Spaces:
Paused
Paused
| """ | |
| 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)} | |