face_verify / liveness_detector.py
subhan971's picture
Upload 9 files
890f045 verified
Raw
History Blame Contribute Delete
11.9 kB
"""
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)}