MorphGuard / MorphVideo /eye_shape_classifier.py
juanquy's picture
Initial clean commit of modular MorphGuard
2978bba
Raw
History Blame Contribute Delete
9.39 kB
"""
Eye Shape Classification for Face Organization
Classifies faces by eye shape characteristics to organize morphing sequences.
"""
import os
import cv2
import numpy as np
from typing import List, Dict, Tuple, Optional
from PIL import Image
import logging
import dlib
from sklearn.cluster import KMeans
logger = logging.getLogger(__name__)
class EyeShapeClassifier:
"""Classifies eye shapes from facial landmarks for video organization."""
def __init__(self, predictor_path: Optional[str] = None):
"""
Initialize the eye shape classifier.
Args:
predictor_path: Path to dlib face landmark predictor
"""
# Use the existing shape predictor from pSp
if predictor_path is None:
predictor_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
'pSp', 'shape_predictor_68_face_landmarks.dat'
)
if not os.path.exists(predictor_path):
raise FileNotFoundError(f"Shape predictor not found: {predictor_path}")
self.detector = dlib.get_frontal_face_detector()
self.predictor = dlib.shape_predictor(predictor_path)
# Eye landmark indices (68-point model)
self.left_eye_indices = list(range(36, 42))
self.right_eye_indices = list(range(42, 48))
# Eye shape categories
self.eye_shapes = {
'almond': 0,
'round': 1,
'hooded': 2,
'upturned': 3,
'downturned': 4,
'deep_set': 5
}
def extract_eye_landmarks(self, image_path: str) -> Optional[Dict[str, np.ndarray]]:
"""
Extract eye landmarks from an image.
Args:
image_path: Path to the image file
Returns:
Dictionary with left and right eye landmarks, or None if face not detected
"""
try:
# Load image
img = cv2.imread(image_path)
if img is None:
logger.warning(f"Could not load image: {image_path}")
return None
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# Detect faces
faces = self.detector(gray)
if len(faces) == 0:
logger.warning(f"No faces detected in: {image_path}")
return None
# Use the largest face
face = max(faces, key=lambda f: f.width() * f.height())
# Get landmarks
landmarks = self.predictor(gray, face)
# Extract eye coordinates
left_eye = np.array([[landmarks.part(i).x, landmarks.part(i).y]
for i in self.left_eye_indices])
right_eye = np.array([[landmarks.part(i).x, landmarks.part(i).y]
for i in self.right_eye_indices])
return {
'left_eye': left_eye,
'right_eye': right_eye,
'face_box': (face.left(), face.top(), face.width(), face.height())
}
except Exception as e:
logger.error(f"Error extracting landmarks from {image_path}: {e}")
return None
def calculate_eye_features(self, landmarks: Dict[str, np.ndarray]) -> np.ndarray:
"""
Calculate eye shape features from landmarks.
Args:
landmarks: Dictionary with eye landmarks
Returns:
Feature vector for eye shape classification
"""
features = []
for eye_key in ['left_eye', 'right_eye']:
eye_points = landmarks[eye_key]
# Calculate eye dimensions
eye_width = np.max(eye_points[:, 0]) - np.min(eye_points[:, 0])
eye_height = np.max(eye_points[:, 1]) - np.min(eye_points[:, 1])
# Aspect ratio
aspect_ratio = eye_width / (eye_height + 1e-6)
# Eye curvature (using top and bottom points)
top_point = eye_points[np.argmin(eye_points[:, 1])]
bottom_point = eye_points[np.argmax(eye_points[:, 1])]
left_point = eye_points[np.argmin(eye_points[:, 0])]
right_point = eye_points[np.argmax(eye_points[:, 0])]
# Calculate angles
outer_angle = self._calculate_angle(left_point, top_point, right_point)
inner_curve = self._calculate_curvature(eye_points)
# Relative position features
centroid = np.mean(eye_points, axis=0)
relative_height = (centroid[1] - np.min(eye_points[:, 1])) / eye_height
features.extend([
aspect_ratio,
outer_angle,
inner_curve,
relative_height,
eye_width,
eye_height
])
return np.array(features)
def _calculate_angle(self, p1: np.ndarray, p2: np.ndarray, p3: np.ndarray) -> float:
"""Calculate angle between three points."""
v1 = p1 - p2
v2 = p3 - p2
cos_angle = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + 1e-6)
angle = np.arccos(np.clip(cos_angle, -1.0, 1.0))
return np.degrees(angle)
def _calculate_curvature(self, points: np.ndarray) -> float:
"""Calculate average curvature of eye shape."""
# Sort points by x-coordinate for consistent ordering
sorted_points = points[np.argsort(points[:, 0])]
curvatures = []
for i in range(1, len(sorted_points) - 1):
p1, p2, p3 = sorted_points[i-1], sorted_points[i], sorted_points[i+1]
# Calculate curvature using circumcircle radius
a = np.linalg.norm(p2 - p1)
b = np.linalg.norm(p3 - p2)
c = np.linalg.norm(p1 - p3)
# Area using cross product
area = 0.5 * abs((p2[0] - p1[0]) * (p3[1] - p1[1]) - (p3[0] - p1[0]) * (p2[1] - p1[1]))
if area > 1e-6:
curvature = (a * b * c) / (4 * area + 1e-6)
curvatures.append(1.0 / (curvature + 1e-6))
return np.mean(curvatures) if curvatures else 0.0
def classify_faces_by_eye_shape(self, image_paths: List[str], n_clusters: int = 6) -> Dict[str, List[str]]:
"""
Classify faces by eye shape using clustering.
Args:
image_paths: List of image file paths
n_clusters: Number of eye shape clusters
Returns:
Dictionary mapping eye shape categories to image paths
"""
features_list = []
valid_paths = []
logger.info(f"Extracting eye features from {len(image_paths)} images...")
# Extract features from all images
for img_path in image_paths:
landmarks = self.extract_eye_landmarks(img_path)
if landmarks is not None:
features = self.calculate_eye_features(landmarks)
features_list.append(features)
valid_paths.append(img_path)
if len(features_list) < n_clusters:
logger.warning(f"Not enough valid faces ({len(features_list)}) for {n_clusters} clusters")
n_clusters = max(1, len(features_list))
logger.info(f"Clustering {len(features_list)} faces into {n_clusters} eye shape groups...")
# Perform clustering
features_array = np.array(features_list)
kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=10)
cluster_labels = kmeans.fit_predict(features_array)
# Group images by cluster
clustered_faces = {}
shape_names = list(self.eye_shapes.keys())[:n_clusters]
for i, shape_name in enumerate(shape_names):
clustered_faces[shape_name] = [
valid_paths[j] for j, label in enumerate(cluster_labels) if label == i
]
# Log results
for shape, paths in clustered_faces.items():
logger.info(f"{shape}: {len(paths)} faces")
return clustered_faces
def get_morphing_sequence(self, clustered_faces: Dict[str, List[str]],
faces_per_group: int = 3) -> List[str]:
"""
Create an optimal morphing sequence across eye shape groups.
Args:
clustered_faces: Dictionary of eye shape groups
faces_per_group: Number of faces to select from each group
Returns:
Ordered list of image paths for smooth morphing
"""
sequence = []
# Select representative faces from each group
for shape_name, face_paths in clustered_faces.items():
if face_paths:
# Select up to faces_per_group images from this cluster
selected = face_paths[:min(faces_per_group, len(face_paths))]
sequence.extend(selected)
logger.info(f"Created morphing sequence with {len(sequence)} faces")
return sequence