""" 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