Spaces:
Sleeping
Sleeping
| # Complete Stress Detection System - 10 Action Units | |
| # Real-time Multi-AU Detection with Comprehensive Analysis | |
| # Research Assistant: [Your Name] | |
| # Guide: Prof. Anup Nandy | |
| # Based on Facial Action Coding System (FACS) - Ekman & Friesen | |
| import cv2 | |
| import mediapipe as mp | |
| import numpy as np | |
| import pandas as pd | |
| import matplotlib.pyplot as plt | |
| from matplotlib.gridspec import GridSpec | |
| from collections import deque | |
| import time | |
| from datetime import datetime | |
| import warnings | |
| warnings.filterwarnings('ignore') | |
| # ==================== CONFIGURATION ==================== | |
| WINDOW_SIZE = 30 | |
| RECORDING_DURATION = 15 | |
| FPS = 30 | |
| # ==================== MediaPipe Setup ==================== | |
| mp_face_mesh = mp.solutions.face_mesh | |
| face_mesh = mp_face_mesh.FaceMesh( | |
| min_detection_confidence=0.5, | |
| min_tracking_confidence=0.5, | |
| refine_landmarks=True | |
| ) | |
| # ==================== LANDMARK INDICES (468 landmarks) ==================== | |
| # AU01 - Inner Brow Raiser (Surprise, Fear, Sadness) | |
| AU01_LANDMARKS = { | |
| 'left_inner_brow': 336, | |
| 'right_inner_brow': 107, | |
| 'nose_bridge': 6, | |
| 'left_outer_brow': 285, | |
| 'right_outer_brow': 55 | |
| } | |
| # AU04 - Brow Lowerer (Anger, Sadness, Concentration) | |
| AU04_LANDMARKS = { | |
| 'left_inner_brow': 336, | |
| 'right_inner_brow': 107, | |
| 'left_mid_brow': 285, | |
| 'right_mid_brow': 55, | |
| 'left_eyelid': 159, | |
| 'right_eyelid': 386, | |
| 'nose_bridge': 6 | |
| } | |
| # AU06 - Cheek Raiser (Genuine Smile - Duchenne) | |
| AU06_LANDMARKS = { | |
| 'left_cheek': 205, | |
| 'right_cheek': 425, | |
| 'left_lower_eyelid': 145, | |
| 'right_lower_eyelid': 374, | |
| 'left_eye_outer': 33, | |
| 'right_eye_outer': 263 | |
| } | |
| # AU07 - Lid Tightener (Concentration, Anger, Disgust) | |
| AU07_LANDMARKS = { | |
| 'left_upper_lid': 159, | |
| 'right_upper_lid': 386, | |
| 'left_lower_lid': 145, | |
| 'right_lower_lid': 374, | |
| 'left_eye_top': 159, | |
| 'right_eye_top': 386 | |
| } | |
| # AU12 - Lip Corner Puller (Happiness) | |
| AU12_LANDMARKS = { | |
| 'left_corner': 61, | |
| 'right_corner': 291, | |
| 'upper_center': 13, | |
| 'lower_center': 14 | |
| } | |
| # AU14 - Dimpler (Smile Intensity) | |
| AU14_LANDMARKS = { | |
| 'left_dimple': 206, | |
| 'right_dimple': 426, | |
| 'left_corner': 61, | |
| 'right_corner': 291 | |
| } | |
| # AU17 - Chin Raiser (Doubt, Sadness, Pouting) | |
| AU17_LANDMARKS = { | |
| 'chin_center': 152, | |
| 'lower_lip': 17, | |
| 'chin_left': 176, | |
| 'chin_right': 400 | |
| } | |
| # AU23 - Lip Tightener (Anger, Tension) | |
| AU23_LANDMARKS = { | |
| 'left_corner': 61, | |
| 'right_corner': 291, | |
| 'left_outer': 57, | |
| 'right_outer': 287 | |
| } | |
| # AU24 - Lip Pressor (Stress, Tension, Anger) | |
| AU24_LANDMARKS = { | |
| 'upper_lip_top': 0, | |
| 'upper_lip_bottom': 13, | |
| 'lower_lip_top': 14, | |
| 'lower_lip_bottom': 17 | |
| } | |
| # AU26 - Jaw Drop (Surprise, Shock, Mouth Opening) | |
| AU26_LANDMARKS = { | |
| 'upper_lip': 13, | |
| 'lower_lip': 14, | |
| 'chin': 152, | |
| 'nose': 1 | |
| } | |
| # ==================== UTILITY FUNCTIONS ==================== | |
| def calculate_distance(point1, point2): | |
| return np.sqrt((point1[0] - point2[0])**2 + (point1[1] - point2[1])**2) | |
| def get_landmark_coords(landmarks, idx, frame_width, frame_height): | |
| lm = landmarks[idx] | |
| return np.array([lm.x * frame_width, lm.y * frame_height]) | |
| # ==================== AU DETECTOR CLASSES ==================== | |
| class AU01Detector: | |
| """AU01 - Inner Brow Raiser (Surprise, Fear, Worry)""" | |
| def __init__(self, window_size=30): | |
| self.name = "AU01_InnerBrowRaise" | |
| self.activation_history = deque(maxlen=window_size) | |
| self.intensity_history = deque(maxlen=window_size) | |
| def detect(self, landmarks, frame_width, frame_height): | |
| left_inner = get_landmark_coords(landmarks, AU01_LANDMARKS['left_inner_brow'], frame_width, frame_height) | |
| right_inner = get_landmark_coords(landmarks, AU01_LANDMARKS['right_inner_brow'], frame_width, frame_height) | |
| left_outer = get_landmark_coords(landmarks, AU01_LANDMARKS['left_outer_brow'], frame_width, frame_height) | |
| right_outer = get_landmark_coords(landmarks, AU01_LANDMARKS['right_outer_brow'], frame_width, frame_height) | |
| nose = get_landmark_coords(landmarks, AU01_LANDMARKS['nose_bridge'], frame_width, frame_height) | |
| # Calculate inner vs outer brow height | |
| inner_height = ((nose[1] - left_inner[1]) + (nose[1] - right_inner[1])) / 2 | |
| outer_height = ((nose[1] - left_outer[1]) + (nose[1] - right_outer[1])) / 2 | |
| # AU01 active when inner brows raised MORE than outer (creates worried look) | |
| raise_ratio = inner_height / (outer_height + 1e-6) | |
| is_active = raise_ratio > 1.15 # Inner brows 15% higher than outer | |
| intensity = min(100, max(0, (raise_ratio - 1.0) * 500)) | |
| self.activation_history.append(int(is_active)) | |
| self.intensity_history.append(intensity) | |
| return is_active, intensity | |
| class AU04Detector: | |
| """AU04 - Brow Lowerer (Anger, Concentration, Stress)""" | |
| def __init__(self, window_size=30): | |
| self.name = "AU04_BrowLower" | |
| self.activation_history = deque(maxlen=window_size) | |
| self.intensity_history = deque(maxlen=window_size) | |
| def detect(self, landmarks, frame_width, frame_height): | |
| left_inner_brow = get_landmark_coords(landmarks, AU04_LANDMARKS['left_inner_brow'], frame_width, frame_height) | |
| right_inner_brow = get_landmark_coords(landmarks, AU04_LANDMARKS['right_inner_brow'], frame_width, frame_height) | |
| left_eyelid = get_landmark_coords(landmarks, AU04_LANDMARKS['left_eyelid'], frame_width, frame_height) | |
| right_eyelid = get_landmark_coords(landmarks, AU04_LANDMARKS['right_eyelid'], frame_width, frame_height) | |
| nose_bridge = get_landmark_coords(landmarks, AU04_LANDMARKS['nose_bridge'], frame_width, frame_height) | |
| left_brow_eyelid_dist = left_inner_brow[1] - left_eyelid[1] | |
| right_brow_eyelid_dist = right_inner_brow[1] - right_eyelid[1] | |
| avg_brow_eyelid_dist = (left_brow_eyelid_dist + right_brow_eyelid_dist) / 2 | |
| face_height = calculate_distance(left_inner_brow, nose_bridge) | |
| normalized_distance = avg_brow_eyelid_dist / (face_height + 1e-6) | |
| inner_brow_distance = calculate_distance(left_inner_brow, right_inner_brow) | |
| outer_eye_distance = calculate_distance(left_eyelid, right_eyelid) | |
| brow_compression_ratio = inner_brow_distance / (outer_eye_distance + 1e-6) | |
| is_active = (normalized_distance > -0.30 or brow_compression_ratio < 0.95) | |
| intensity = min(100, max(0, (normalized_distance + 0.40) / 0.40 * 100)) | |
| self.activation_history.append(int(is_active)) | |
| self.intensity_history.append(intensity) | |
| return is_active, intensity | |
| class AU06Detector: | |
| """AU06 - Cheek Raiser (Genuine Smile)""" | |
| def __init__(self, window_size=30): | |
| self.name = "AU06_CheekRaise" | |
| self.activation_history = deque(maxlen=window_size) | |
| self.intensity_history = deque(maxlen=window_size) | |
| def detect(self, landmarks, frame_width, frame_height): | |
| left_cheek = get_landmark_coords(landmarks, AU06_LANDMARKS['left_cheek'], frame_width, frame_height) | |
| right_cheek = get_landmark_coords(landmarks, AU06_LANDMARKS['right_cheek'], frame_width, frame_height) | |
| left_lower_lid = get_landmark_coords(landmarks, AU06_LANDMARKS['left_lower_eyelid'], frame_width, frame_height) | |
| right_lower_lid = get_landmark_coords(landmarks, AU06_LANDMARKS['right_lower_eyelid'], frame_width, frame_height) | |
| # When cheeks raise, distance between cheek and lower eyelid decreases | |
| left_distance = calculate_distance(left_cheek, left_lower_lid) | |
| right_distance = calculate_distance(right_cheek, right_lower_lid) | |
| avg_distance = (left_distance + right_distance) / 2 | |
| # Also check if lower eyelid moves up | |
| left_eye_outer = get_landmark_coords(landmarks, AU06_LANDMARKS['left_eye_outer'], frame_width, frame_height) | |
| eye_height = abs(left_eye_outer[1] - left_lower_lid[1]) | |
| cheek_raise_score = eye_height / (avg_distance + 1e-6) | |
| is_active = cheek_raise_score > 0.8 | |
| intensity = min(100, max(0, (cheek_raise_score - 0.5) * 200)) | |
| self.activation_history.append(int(is_active)) | |
| self.intensity_history.append(intensity) | |
| return is_active, intensity | |
| class AU07Detector: | |
| """AU07 - Lid Tightener (Tension, Squinting)""" | |
| def __init__(self, window_size=30): | |
| self.name = "AU07_LidTighten" | |
| self.activation_history = deque(maxlen=window_size) | |
| self.intensity_history = deque(maxlen=window_size) | |
| def detect(self, landmarks, frame_width, frame_height): | |
| left_upper = get_landmark_coords(landmarks, AU07_LANDMARKS['left_upper_lid'], frame_width, frame_height) | |
| right_upper = get_landmark_coords(landmarks, AU07_LANDMARKS['right_upper_lid'], frame_width, frame_height) | |
| left_lower = get_landmark_coords(landmarks, AU07_LANDMARKS['left_lower_lid'], frame_width, frame_height) | |
| right_lower = get_landmark_coords(landmarks, AU07_LANDMARKS['right_lower_lid'], frame_width, frame_height) | |
| # Eye opening (smaller = more tightened) | |
| left_eye_opening = abs(left_upper[1] - left_lower[1]) | |
| right_eye_opening = abs(right_upper[1] - right_lower[1]) | |
| avg_eye_opening = (left_eye_opening + right_eye_opening) / 2 | |
| # Normalize by face height | |
| face_ref = calculate_distance(left_upper, | |
| get_landmark_coords(landmarks, 152, frame_width, frame_height)) | |
| normalized_opening = avg_eye_opening / (face_ref + 1e-6) | |
| is_active = normalized_opening < 0.025 # Eyes tightened/squinted | |
| intensity = min(100, max(0, (0.035 - normalized_opening) / 0.035 * 100)) | |
| self.activation_history.append(int(is_active)) | |
| self.intensity_history.append(intensity) | |
| return is_active, intensity | |
| class AU12Detector: | |
| """AU12 - Lip Corner Puller (Smile)""" | |
| def __init__(self, window_size=30): | |
| self.name = "AU12_LipCornerPull" | |
| self.activation_history = deque(maxlen=window_size) | |
| self.intensity_history = deque(maxlen=window_size) | |
| def detect(self, landmarks, frame_width, frame_height): | |
| left_corner = get_landmark_coords(landmarks, AU12_LANDMARKS['left_corner'], frame_width, frame_height) | |
| right_corner = get_landmark_coords(landmarks, AU12_LANDMARKS['right_corner'], frame_width, frame_height) | |
| upper_center = get_landmark_coords(landmarks, AU12_LANDMARKS['upper_center'], frame_width, frame_height) | |
| lower_center = get_landmark_coords(landmarks, AU12_LANDMARKS['lower_center'], frame_width, frame_height) | |
| mouth_width = calculate_distance(left_corner, right_corner) | |
| mouth_height = calculate_distance(upper_center, lower_center) | |
| mouth_center_y = (upper_center[1] + lower_center[1]) / 2 | |
| left_corner_lift = mouth_center_y - left_corner[1] | |
| right_corner_lift = mouth_center_y - right_corner[1] | |
| avg_corner_lift = (left_corner_lift + right_corner_lift) / 2 | |
| mouth_ratio = mouth_width / (mouth_height + 1e-6) | |
| normalized_lift = avg_corner_lift / mouth_height if mouth_height > 0 else 0 | |
| lift_difference = abs(left_corner_lift - right_corner_lift) | |
| symmetry_score = 1.0 - min(1.0, lift_difference / (mouth_height + 1e-6)) | |
| is_active = (normalized_lift > 0.25 and mouth_ratio > 2.8 and symmetry_score > 0.6) | |
| intensity = min(100, max(0, normalized_lift * 250)) | |
| self.activation_history.append(int(is_active)) | |
| self.intensity_history.append(intensity) | |
| return is_active, intensity | |
| class AU14Detector: | |
| """AU14 - Dimpler (Smile Depth Indicator)""" | |
| def __init__(self, window_size=30): | |
| self.name = "AU14_Dimpler" | |
| self.activation_history = deque(maxlen=window_size) | |
| self.intensity_history = deque(maxlen=window_size) | |
| def detect(self, landmarks, frame_width, frame_height): | |
| left_dimple = get_landmark_coords(landmarks, AU14_LANDMARKS['left_dimple'], frame_width, frame_height) | |
| right_dimple = get_landmark_coords(landmarks, AU14_LANDMARKS['right_dimple'], frame_width, frame_height) | |
| left_corner = get_landmark_coords(landmarks, AU14_LANDMARKS['left_corner'], frame_width, frame_height) | |
| right_corner = get_landmark_coords(landmarks, AU14_LANDMARKS['right_corner'], frame_width, frame_height) | |
| # Dimples appear when corners pull back and create indentation | |
| left_depth = calculate_distance(left_dimple, left_corner) | |
| right_depth = calculate_distance(right_dimple, right_corner) | |
| avg_depth = (left_depth + right_depth) / 2 | |
| # Check corner retraction | |
| corner_distance = calculate_distance(left_corner, right_corner) | |
| dimple_score = avg_depth / (corner_distance + 1e-6) | |
| is_active = dimple_score > 0.15 | |
| intensity = min(100, max(0, (dimple_score - 0.10) * 500)) | |
| self.activation_history.append(int(is_active)) | |
| self.intensity_history.append(intensity) | |
| return is_active, intensity | |
| class AU17Detector: | |
| """AU17 - Chin Raiser (Doubt, Pouting, Sadness)""" | |
| def __init__(self, window_size=30): | |
| self.name = "AU17_ChinRaise" | |
| self.activation_history = deque(maxlen=window_size) | |
| self.intensity_history = deque(maxlen=window_size) | |
| def detect(self, landmarks, frame_width, frame_height): | |
| chin = get_landmark_coords(landmarks, AU17_LANDMARKS['chin_center'], frame_width, frame_height) | |
| lower_lip = get_landmark_coords(landmarks, AU17_LANDMARKS['lower_lip'], frame_width, frame_height) | |
| # When chin raises, distance between chin and lower lip decreases | |
| chin_lip_distance = calculate_distance(chin, lower_lip) | |
| # Normalize by face height | |
| nose = get_landmark_coords(landmarks, 1, frame_width, frame_height) | |
| face_height = calculate_distance(nose, chin) | |
| normalized_distance = chin_lip_distance / (face_height + 1e-6) | |
| is_active = normalized_distance < 0.08 # Chin pushed up | |
| intensity = min(100, max(0, (0.12 - normalized_distance) / 0.12 * 100)) | |
| self.activation_history.append(int(is_active)) | |
| self.intensity_history.append(intensity) | |
| return is_active, intensity | |
| class AU23Detector: | |
| """AU23 - Lip Tightener (Anger, Tension)""" | |
| def __init__(self, window_size=30): | |
| self.name = "AU23_LipTighten" | |
| self.activation_history = deque(maxlen=window_size) | |
| self.intensity_history = deque(maxlen=window_size) | |
| def detect(self, landmarks, frame_width, frame_height): | |
| left_corner = get_landmark_coords(landmarks, AU23_LANDMARKS['left_corner'], frame_width, frame_height) | |
| right_corner = get_landmark_coords(landmarks, AU23_LANDMARKS['right_corner'], frame_width, frame_height) | |
| left_outer = get_landmark_coords(landmarks, AU23_LANDMARKS['left_outer'], frame_width, frame_height) | |
| right_outer = get_landmark_coords(landmarks, AU23_LANDMARKS['right_outer'], frame_width, frame_height) | |
| corner_width = calculate_distance(left_corner, right_corner) | |
| outer_width = calculate_distance(left_outer, right_outer) | |
| tightness_ratio = corner_width / (outer_width + 1e-6) | |
| is_active = (tightness_ratio < 0.85) | |
| intensity = min(100, max(0, (0.95 - tightness_ratio) / 0.20 * 100)) | |
| self.activation_history.append(int(is_active)) | |
| self.intensity_history.append(intensity) | |
| return is_active, intensity | |
| class AU24Detector: | |
| """AU24 - Lip Pressor (Stress, Tension)""" | |
| def __init__(self, window_size=30): | |
| self.name = "AU24_LipPress" | |
| self.activation_history = deque(maxlen=window_size) | |
| self.intensity_history = deque(maxlen=window_size) | |
| def detect(self, landmarks, frame_width, frame_height): | |
| upper_lip_top = get_landmark_coords(landmarks, AU24_LANDMARKS['upper_lip_top'], frame_width, frame_height) | |
| upper_lip_bottom = get_landmark_coords(landmarks, AU24_LANDMARKS['upper_lip_bottom'], frame_width, frame_height) | |
| lower_lip_top = get_landmark_coords(landmarks, AU24_LANDMARKS['lower_lip_top'], frame_width, frame_height) | |
| lower_lip_bottom = get_landmark_coords(landmarks, AU24_LANDMARKS['lower_lip_bottom'], frame_width, frame_height) | |
| upper_lip_thickness = calculate_distance(upper_lip_top, upper_lip_bottom) | |
| lower_lip_thickness = calculate_distance(lower_lip_top, lower_lip_bottom) | |
| total_lip_thickness = upper_lip_thickness + lower_lip_thickness | |
| mouth_opening = calculate_distance(upper_lip_bottom, lower_lip_top) | |
| lip_press_score = mouth_opening / (total_lip_thickness + 1e-6) | |
| is_active = (lip_press_score < 0.4 and total_lip_thickness < 15) | |
| intensity = min(100, max(0, (0.6 - lip_press_score) / 0.6 * 100)) | |
| self.activation_history.append(int(is_active)) | |
| self.intensity_history.append(intensity) | |
| return is_active, intensity | |
| class AU26Detector: | |
| """AU26 - Jaw Drop (Surprise, Shock)""" | |
| def __init__(self, window_size=30): | |
| self.name = "AU26_JawDrop" | |
| self.activation_history = deque(maxlen=window_size) | |
| self.intensity_history = deque(maxlen=window_size) | |
| def detect(self, landmarks, frame_width, frame_height): | |
| upper_lip = get_landmark_coords(landmarks, AU26_LANDMARKS['upper_lip'], frame_width, frame_height) | |
| lower_lip = get_landmark_coords(landmarks, AU26_LANDMARKS['lower_lip'], frame_width, frame_height) | |
| chin = get_landmark_coords(landmarks, AU26_LANDMARKS['chin'], frame_width, frame_height) | |
| nose = get_landmark_coords(landmarks, AU26_LANDMARKS['nose'], frame_width, frame_height) | |
| # Mouth opening | |
| mouth_opening = calculate_distance(upper_lip, lower_lip) | |
| # Jaw drop (distance from nose to chin increases) | |
| jaw_length = calculate_distance(nose, chin) | |
| # Normalize | |
| mouth_opening_ratio = mouth_opening / (jaw_length + 1e-6) | |
| is_active = mouth_opening_ratio > 0.15 # Mouth significantly open | |
| intensity = min(100, max(0, (mouth_opening_ratio - 0.10) / 0.20 * 100)) | |
| self.activation_history.append(int(is_active)) | |
| self.intensity_history.append(intensity) | |
| return is_active, intensity | |
| # ==================== FEATURE EXTRACTOR ==================== | |
| class MultiAUFeatureExtractor: | |
| def __init__(self, detectors): | |
| self.detectors = detectors | |
| self.feature_log = [] | |
| def extract_features(self, timestamp): | |
| features = {'timestamp': timestamp} | |
| for detector in self.detectors: | |
| is_active = detector.activation_history[-1] if detector.activation_history else 0 | |
| intensity = detector.intensity_history[-1] if detector.intensity_history else 0 | |
| activation_rate = sum(detector.activation_history) / len(detector.activation_history) if detector.activation_history else 0 | |
| avg_intensity = np.mean(detector.intensity_history) if detector.intensity_history else 0 | |
| max_intensity = np.max(detector.intensity_history) if detector.intensity_history else 0 | |
| intensity_std = np.std(detector.intensity_history) if detector.intensity_history else 0 | |
| features[f'{detector.name}_active'] = is_active | |
| features[f'{detector.name}_intensity'] = intensity | |
| features[f'{detector.name}_activation_rate'] = activation_rate | |
| features[f'{detector.name}_avg_intensity'] = avg_intensity | |
| features[f'{detector.name}_max_intensity'] = max_intensity | |
| features[f'{detector.name}_intensity_std'] = intensity_std | |
| self.feature_log.append(features) | |
| return features | |
| def get_dataframe(self): | |
| return pd.DataFrame(self.feature_log) | |
| def save_features(self, filename): | |
| df = self.get_dataframe() | |
| df.to_csv(filename, index=False) | |
| print(f"β Features saved to {filename}") | |
| # ==================== DETECTION SESSION ==================== | |
| def run_detection_session(duration_seconds=15, save_data=True): | |
| # Initialize all 10 AU detectors | |
| au01 = AU01Detector() | |
| au04 = AU04Detector() | |
| au06 = AU06Detector() | |
| au07 = AU07Detector() | |
| au12 = AU12Detector() | |
| au14 = AU14Detector() | |
| au17 = AU17Detector() | |
| au23 = AU23Detector() | |
| au24 = AU24Detector() | |
| au26 = AU26Detector() | |
| detectors = [au01, au04, au06, au07, au12, au14, au17, au23, au24, au26] | |
| feature_extractor = MultiAUFeatureExtractor(detectors) | |
| cap = cv2.VideoCapture(0) | |
| print(f"\n{'='*70}") | |
| print(f" COMPLETE 10-AU STRESS DETECTION SYSTEM") | |
| print(f" Recording for {duration_seconds} seconds...") | |
| print(f"{'='*70}\n") | |
| start_time = time.time() | |
| frame_count = 0 | |
| while True: | |
| ret, frame = cap.read() | |
| if not ret: | |
| break | |
| current_time = time.time() | |
| elapsed = current_time - start_time | |
| if elapsed >= duration_seconds: | |
| break | |
| frame = cv2.flip(frame, 1) | |
| frame_height, frame_width = frame.shape[:2] | |
| rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) | |
| results = face_mesh.process(rgb_frame) | |
| if results.multi_face_landmarks: | |
| landmarks = results.multi_face_landmarks[0].landmark | |
| # Detect all 10 AUs | |
| au01_active, au01_intensity = au01.detect(landmarks, frame_width, frame_height) | |
| au04_active, au04_intensity = au04.detect(landmarks, frame_width, frame_height) | |
| au06_active, au06_intensity = au06.detect(landmarks, frame_width, frame_height) | |
| au07_active, au07_intensity = au07.detect(landmarks, frame_width, frame_height) | |
| au12_active, au12_intensity = au12.detect(landmarks, frame_width, frame_height) | |
| au14_active, au14_intensity = au14.detect(landmarks, frame_width, frame_height) | |
| au17_active, au17_intensity = au17.detect(landmarks, frame_width, frame_height) | |
| au23_active, au23_intensity = au23.detect(landmarks, frame_width, frame_height) | |
| au24_active, au24_intensity = au24.detect(landmarks, frame_width, frame_height) | |
| au26_active, au26_intensity = au26.detect(landmarks, frame_width, frame_height) | |
| features = feature_extractor.extract_features(elapsed) | |
| # Display (2 columns) | |
| y_offset = 25 | |
| col1_x = 10 | |
| col2_x = frame_width // 2 + 10 | |
| # Header | |
| cv2.putText(frame, f"Time: {elapsed:.1f}s / {duration_seconds}s", | |
| (10, y_offset), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2) | |
| y_offset += 35 | |
| # Column 1: Stress Indicators | |
| cv2.putText(frame, "STRESS INDICATORS:", (col1_x, y_offset), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 2) | |
| y_offset += 25 | |
| stress_aus = [ | |
| (au01_active, au01_intensity, "AU01-BrowRaise"), | |
| (au04_active, au04_intensity, "AU04-BrowLower"), | |
| (au07_active, au07_intensity, "AU07-LidTight"), | |
| (au17_active, au17_intensity, "AU17-ChinRaise"), | |
| (au23_active, au23_intensity, "AU23-LipTight"), | |
| (au24_active, au24_intensity, "AU24-LipPress") | |
| ] | |
| for active, intensity, name in stress_aus: | |
| color = (0, 0, 255) if active else (100, 100, 100) | |
| cv2.putText(frame, f"{name}: {intensity:.0f}%", | |
| (col1_x, y_offset), cv2.FONT_HERSHEY_SIMPLEX, 0.4, color, 1) | |
| y_offset += 20 | |
| # Column 2: Positive Indicators | |
| y_offset = 60 | |
| cv2.putText(frame, "POSITIVE INDICATORS:", (col2_x, y_offset), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2) | |
| y_offset += 25 | |
| positive_aus = [ | |
| (au06_active, au06_intensity, "AU06-CheekRaise"), | |
| (au12_active, au12_intensity, "AU12-SmilePull"), | |
| (au14_active, au14_intensity, "AU14-Dimpler"), | |
| (au26_active, au26_intensity, "AU26-JawDrop") | |
| ] | |
| for active, intensity, name in positive_aus: | |
| color = (0, 255, 0) if active else (100, 100, 100) | |
| cv2.putText(frame, f"{name}: {intensity:.0f}%", | |
| (col2_x, y_offset), cv2.FONT_HERSHEY_SIMPLEX, 0.4, color, 1) | |
| y_offset += 20 | |
| # Bottom summary | |
| stress_count = sum([au01_active, au04_active, au07_active, au17_active, au23_active, au24_active]) | |
| positive_count = sum([au06_active, au12_active, au14_active]) | |
| cv2.rectangle(frame, (10, frame_height - 60), (frame_width - 10, frame_height - 10), (50, 50, 50), -1) | |
| cv2.putText(frame, f"Stress AUs: {stress_count}/6 | Positive AUs: {positive_count}/4", | |
| (20, frame_height - 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2) | |
| cv2.imshow('Complete 10-AU Stress Detection', frame) | |
| frame_count += 1 | |
| if cv2.waitKey(1) & 0xFF == ord('q'): | |
| break | |
| cap.release() | |
| cv2.destroyAllWindows() | |
| if save_data: | |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| filename = f"complete_au_features_{timestamp}.csv" | |
| feature_extractor.save_features(filename) | |
| print(f"\nβ Session complete! Processed {frame_count} frames") | |
| print(f"β Average FPS: {frame_count/duration_seconds:.1f}") | |
| return feature_extractor.get_dataframe() | |
| # ==================== ADVANCED ANALYSIS ==================== | |
| def calculate_comprehensive_stress_score(df): | |
| """ | |
| Research-based stress scoring using all 10 AUs | |
| Weights based on affective computing literature | |
| """ | |
| # STRESS INDICATORS (weighted by research evidence) | |
| # AU04 (Brow Lower) - Primary anger/stress indicator | |
| au04_score = (df['AU04_BrowLower_intensity'].mean() / 100) * (df['AU04_BrowLower_activation_rate'].mean()) * 25 | |
| # AU01 (Inner Brow Raise) - Worry/sadness indicator | |
| au01_score = (df['AU01_InnerBrowRaise_intensity'].mean() / 100) * (df['AU01_InnerBrowRaise_activation_rate'].mean()) * 15 | |
| # AU07 (Lid Tighten) - Tension indicator | |
| au07_score = (df['AU07_LidTighten_intensity'].mean() / 100) * (df['AU07_LidTighten_activation_rate'].mean()) * 12 | |
| # AU24 (Lip Press) - Stress/tension | |
| au24_score = (df['AU24_LipPress_intensity'].mean() / 100) * (df['AU24_LipPress_activation_rate'].mean()) * 15 | |
| # AU23 (Lip Tighten) - Anger/tension | |
| au23_score = (df['AU23_LipTighten_intensity'].mean() / 100) * (df['AU23_LipTighten_activation_rate'].mean()) * 12 | |
| # AU17 (Chin Raise) - Doubt/sadness | |
| au17_score = (df['AU17_ChinRaise_intensity'].mean() / 100) * (df['AU17_ChinRaise_activation_rate'].mean()) * 8 | |
| # POSITIVE INDICATORS (reduce stress score) | |
| # AU06 + AU12 (Duchenne Smile - genuine happiness) | |
| duchenne_smile = ((df['AU06_CheekRaise_active'] == 1) & (df['AU12_LipCornerPull_active'] == 1)).sum() / len(df) | |
| positive_reduction = duchenne_smile * 15 | |
| # AU12 alone (social smile - may mask stress) | |
| social_smile = ((df['AU12_LipCornerPull_active'] == 1) & (df['AU06_CheekRaise_active'] == 0)).sum() / len(df) | |
| masking_indicator = social_smile * 5 # Adds to stress if smiling without cheek raise | |
| # TEMPORAL PATTERNS | |
| # Sustained stress (continuous activation 3+ seconds) | |
| sustained_stress = 0 | |
| for au_name in ['AU04_BrowLower', 'AU24_LipPress', 'AU23_LipTighten']: | |
| streak = 0 | |
| for val in df[f'{au_name}_active']: | |
| if val == 1: | |
| streak += 1 | |
| if streak >= 90: # 3 seconds at 30fps | |
| sustained_stress += 1 | |
| break | |
| else: | |
| streak = 0 | |
| sustained_score = min(10, sustained_stress * 3) | |
| # Co-occurrence of multiple stress AUs | |
| stress_cols = ['AU01_InnerBrowRaise_active', 'AU04_BrowLower_active', | |
| 'AU07_LidTighten_active', 'AU23_LipTighten_active', | |
| 'AU24_LipPress_active', 'AU17_ChinRaise_active'] | |
| co_occurrence = (df[stress_cols].sum(axis=1) >= 3).sum() / len(df) | |
| co_occurrence_score = co_occurrence * 8 | |
| # COMBINED STRESS SCORE | |
| raw_stress = (au04_score + au01_score + au07_score + au24_score + | |
| au23_score + au17_score + sustained_score + | |
| co_occurrence_score + masking_indicator - positive_reduction) | |
| stress_score = min(100, max(0, raw_stress)) | |
| # Classification | |
| if stress_score < 25: | |
| classification = "NOT STRESSED" | |
| color = "π’" | |
| elif stress_score < 55: | |
| classification = "POSSIBLY STRESSED" | |
| color = "π‘" | |
| else: | |
| classification = "STRESSED" | |
| color = "π΄" | |
| return { | |
| 'classification': classification, | |
| 'color': color, | |
| 'stress_score': stress_score, | |
| 'components': { | |
| 'au04': au04_score, | |
| 'au01': au01_score, | |
| 'au07': au07_score, | |
| 'au24': au24_score, | |
| 'au23': au23_score, | |
| 'au17': au17_score, | |
| 'sustained': sustained_score, | |
| 'co_occurrence': co_occurrence_score, | |
| 'duchenne_smile': duchenne_smile, | |
| 'social_smile_masking': masking_indicator | |
| }, | |
| 'activation_percentages': { | |
| 'AU01': (df['AU01_InnerBrowRaise_active'].sum() / len(df)) * 100, | |
| 'AU04': (df['AU04_BrowLower_active'].sum() / len(df)) * 100, | |
| 'AU06': (df['AU06_CheekRaise_active'].sum() / len(df)) * 100, | |
| 'AU07': (df['AU07_LidTighten_active'].sum() / len(df)) * 100, | |
| 'AU12': (df['AU12_LipCornerPull_active'].sum() / len(df)) * 100, | |
| 'AU14': (df['AU14_Dimpler_active'].sum() / len(df)) * 100, | |
| 'AU17': (df['AU17_ChinRaise_active'].sum() / len(df)) * 100, | |
| 'AU23': (df['AU23_LipTighten_active'].sum() / len(df)) * 100, | |
| 'AU24': (df['AU24_LipPress_active'].sum() / len(df)) * 100, | |
| 'AU26': (df['AU26_JawDrop_active'].sum() / len(df)) * 100 | |
| } | |
| } | |
| # ==================== COMPREHENSIVE VISUALIZATION ==================== | |
| def plot_comprehensive_analysis(df): | |
| """Create 10 publication-quality plots""" | |
| fig = plt.figure(figsize=(20, 16)) | |
| gs = GridSpec(5, 3, figure=fig, hspace=0.35, wspace=0.3) | |
| fig.suptitle('Comprehensive 10-AU Facial Expression Analysis\nStress Detection System', | |
| fontsize=18, fontweight='bold', y=0.995) | |
| # Plot 1: All AU Activations Over Time | |
| ax1 = fig.add_subplot(gs[0, :2]) | |
| stress_aus = ['AU01_InnerBrowRaise', 'AU04_BrowLower', 'AU07_LidTighten', | |
| 'AU17_ChinRaise', 'AU23_LipTighten', 'AU24_LipPress'] | |
| colors_stress = ['orange', 'red', 'darkred', 'brown', 'purple', 'magenta'] | |
| for au, color in zip(stress_aus, colors_stress): | |
| ax1.plot(df['timestamp'], df[f'{au}_active'], label=au.split('_')[1], | |
| color=color, linewidth=1.5, alpha=0.7) | |
| ax1.set_xlabel('Time (seconds)', fontweight='bold') | |
| ax1.set_ylabel('Activation (Binary)', fontweight='bold') | |
| ax1.set_title('Stress-Related AU Temporal Patterns') | |
| ax1.legend(loc='upper right', ncol=3, fontsize=8) | |
| ax1.grid(True, alpha=0.3) | |
| ax1.set_ylim(-0.1, 1.1) | |
| # Plot 2: Positive AUs Over Time | |
| ax2 = fig.add_subplot(gs[0, 2]) | |
| positive_aus = ['AU06_CheekRaise', 'AU12_LipCornerPull', 'AU14_Dimpler', 'AU26_JawDrop'] | |
| colors_pos = ['lightgreen', 'green', 'darkgreen', 'blue'] | |
| for au, color in zip(positive_aus, colors_pos): | |
| ax2.plot(df['timestamp'], df[f'{au}_active'], label=au.split('_')[1], | |
| color=color, linewidth=1.5, alpha=0.7) | |
| ax2.set_xlabel('Time (s)', fontweight='bold') | |
| ax2.set_ylabel('Activation', fontweight='bold') | |
| ax2.set_title('Positive AU Patterns') | |
| ax2.legend(fontsize=7) | |
| ax2.grid(True, alpha=0.3) | |
| ax2.set_ylim(-0.1, 1.1) | |
| # Plot 3: Intensity Heatmap (All 10 AUs) | |
| ax3 = fig.add_subplot(gs[1, :]) | |
| all_aus = ['AU01_InnerBrowRaise', 'AU04_BrowLower', 'AU06_CheekRaise', | |
| 'AU07_LidTighten', 'AU12_LipCornerPull', 'AU14_Dimpler', | |
| 'AU17_ChinRaise', 'AU23_LipTighten', 'AU24_LipPress', 'AU26_JawDrop'] | |
| intensity_data = df[[f'{au}_intensity' for au in all_aus]].T | |
| im = ax3.imshow(intensity_data, aspect='auto', cmap='RdYlGn_r', interpolation='nearest') | |
| ax3.set_yticks(range(10)) | |
| ax3.set_yticklabels([au.split('_')[0] for au in all_aus]) | |
| ax3.set_xlabel('Frame Number', fontweight='bold') | |
| ax3.set_title('Complete AU Intensity Heatmap (All 10 Action Units)') | |
| plt.colorbar(im, ax=ax3, label='Intensity (%)') | |
| # Plot 4: AU Activation Frequency Bar Chart | |
| ax4 = fig.add_subplot(gs[2, 0]) | |
| result = calculate_comprehensive_stress_score(df) | |
| au_names = list(result['activation_percentages'].keys()) | |
| au_values = list(result['activation_percentages'].values()) | |
| colors = ['red' if 'AU04' in au or 'AU24' in au or 'AU23' in au | |
| else 'orange' if 'AU01' in au or 'AU07' in au or 'AU17' in au | |
| else 'green' for au in au_names] | |
| bars = ax4.barh(au_names, au_values, color=colors, alpha=0.7) | |
| ax4.set_xlabel('Activation Percentage (%)', fontweight='bold') | |
| ax4.set_title('AU Activation Frequencies') | |
| ax4.grid(True, alpha=0.3, axis='x') | |
| # Plot 5: Duchenne vs Non-Duchenne Smile Detection | |
| ax5 = fig.add_subplot(gs[2, 1]) | |
| duchenne = ((df['AU06_CheekRaise_active'] == 1) & (df['AU12_LipCornerPull_active'] == 1)).sum() | |
| non_duchenne = ((df['AU12_LipCornerPull_active'] == 1) & (df['AU06_CheekRaise_active'] == 0)).sum() | |
| no_smile = len(df) - duchenne - non_duchenne | |
| labels = ['Genuine\n(Duchenne)', 'Social\n(Masking)', 'No Smile'] | |
| sizes = [duchenne, non_duchenne, no_smile] | |
| colors_pie = ['green', 'yellow', 'lightgray'] | |
| ax5.pie(sizes, labels=labels, colors=colors_pie, autopct='%1.1f%%', startangle=90) | |
| ax5.set_title('Smile Type Distribution') | |
| # Plot 6: Stress vs Positive Balance | |
| ax6 = fig.add_subplot(gs[2, 2]) | |
| stress_intensity_avg = df[['AU01_InnerBrowRaise_intensity', 'AU04_BrowLower_intensity', | |
| 'AU07_LidTighten_intensity', 'AU23_LipTighten_intensity', | |
| 'AU24_LipPress_intensity', 'AU17_ChinRaise_intensity']].mean(axis=1) | |
| positive_intensity_avg = df[['AU06_CheekRaise_intensity', 'AU12_LipCornerPull_intensity', | |
| 'AU14_Dimpler_intensity']].mean(axis=1) | |
| ax6.plot(df['timestamp'], stress_intensity_avg, color='red', linewidth=2, label='Stress AUs') | |
| ax6.plot(df['timestamp'], positive_intensity_avg, color='green', linewidth=2, label='Positive AUs') | |
| ax6.fill_between(df['timestamp'], stress_intensity_avg, alpha=0.3, color='red') | |
| ax6.fill_between(df['timestamp'], positive_intensity_avg, alpha=0.3, color='green') | |
| ax6.set_xlabel('Time (seconds)', fontweight='bold') | |
| ax6.set_ylabel('Average Intensity (%)', fontweight='bold') | |
| ax6.set_title('Stress vs Positive Affect Balance') | |
| ax6.legend() | |
| ax6.grid(True, alpha=0.3) | |
| # Plot 7: Correlation Matrix (All AUs) | |
| ax7 = fig.add_subplot(gs[3, :2]) | |
| correlation_cols = [f'{au}_intensity' for au in all_aus] | |
| corr_matrix = df[correlation_cols].corr() | |
| im = ax7.imshow(corr_matrix, cmap='coolwarm', vmin=-1, vmax=1, aspect='auto') | |
| ax7.set_xticks(range(10)) | |
| ax7.set_yticks(range(10)) | |
| ax7.set_xticklabels([au.split('_')[0] for au in all_aus], rotation=45, ha='right') | |
| ax7.set_yticklabels([au.split('_')[0] for au in all_aus]) | |
| ax7.set_title('Complete AU Correlation Matrix') | |
| # Add correlation values | |
| for i in range(10): | |
| for j in range(10): | |
| if abs(corr_matrix.iloc[i, j]) > 0.3: # Only show strong correlations | |
| ax7.text(j, i, f'{corr_matrix.iloc[i, j]:.2f}', | |
| ha="center", va="center", color="black", fontsize=7) | |
| plt.colorbar(im, ax=ax7) | |
| # Plot 8: Time-Windowed Stress Evolution | |
| ax8 = fig.add_subplot(gs[3, 2]) | |
| window_size = 90 # 3 seconds | |
| windowed_stress = [] | |
| window_times = [] | |
| for i in range(0, len(df) - window_size, window_size // 2): | |
| window_df = df.iloc[i:i+window_size] | |
| if len(window_df) > 0: | |
| window_result = calculate_comprehensive_stress_score(window_df) | |
| windowed_stress.append(window_result['stress_score']) | |
| window_times.append(window_df['timestamp'].mean()) | |
| ax8.plot(window_times, windowed_stress, color='red', linewidth=2, marker='o') | |
| ax8.fill_between(window_times, windowed_stress, alpha=0.3, color='red') | |
| ax8.axhline(y=25, color='green', linestyle='--', label='Low threshold', alpha=0.5) | |
| ax8.axhline(y=55, color='orange', linestyle='--', label='High threshold', alpha=0.5) | |
| ax8.set_xlabel('Time (seconds)', fontweight='bold') | |
| ax8.set_ylabel('Stress Score', fontweight='bold') | |
| ax8.set_title('Stress Score Evolution (3s windows)') | |
| ax8.legend() | |
| ax8.grid(True, alpha=0.3) | |
| # Plot 9: AU Co-occurrence Matrix | |
| ax9 = fig.add_subplot(gs[4, 0]) | |
| stress_au_cols = [f'{au}_active' for au in stress_aus] | |
| co_occur_matrix = np.zeros((6, 6)) | |
| for i in range(6): | |
| for j in range(6): | |
| co_occur = ((df[stress_au_cols[i]] == 1) & (df[stress_au_cols[j]] == 1)).sum() | |
| co_occur_matrix[i, j] = co_occur / len(df) * 100 | |
| im = ax9.imshow(co_occur_matrix, cmap='Reds', aspect='auto') | |
| ax9.set_xticks(range(6)) | |
| ax9.set_yticks(range(6)) | |
| ax9.set_xticklabels([au.split('_')[0] for au in stress_aus], rotation=45, ha='right') | |
| ax9.set_yticklabels([au.split('_')[0] for au in stress_aus]) | |
| ax9.set_title('Stress AU Co-occurrence (%)') | |
| plt.colorbar(im, ax=ax9) | |
| # Plot 10: Comprehensive Summary Report | |
| ax10 = fig.add_subplot(gs[4, 1:]) | |
| ax10.axis('off') | |
| result = calculate_comprehensive_stress_score(df) | |
| summary_text = f""" | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| β COMPREHENSIVE STRESS ASSESSMENT REPORT β | |
| β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£ | |
| β β | |
| β CLASSIFICATION: {result['color']} {result['classification']:<25} | STRESS SCORE: {result['stress_score']:.1f}/100 β | |
| β β | |
| β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£ | |
| β COMPONENT CONTRIBUTIONS: β | |
| β β’ AU04 (Brow Lower): {result['components']['au04']:.2f} / 25.0 [{result['activation_percentages']['AU04']:5.1f}% active] β | |
| β β’ AU01 (Inner Brow): {result['components']['au01']:.2f} / 15.0 [{result['activation_percentages']['AU01']:5.1f}% active] β | |
| β β’ AU07 (Lid Tighten): {result['components']['au07']:.2f} / 12.0 [{result['activation_percentages']['AU07']:5.1f}% active] β | |
| β β’ AU24 (Lip Press): {result['components']['au24']:.2f} / 15.0 [{result['activation_percentages']['AU24']:5.1f}% active] β | |
| β β’ AU23 (Lip Tighten): {result['components']['au23']:.2f} / 12.0 [{result['activation_percentages']['AU23']:5.1f}% active] β | |
| β β’ AU17 (Chin Raise): {result['components']['au17']:.2f} / 8.0 [{result['activation_percentages']['AU17']:5.1f}% active] β | |
| β β’ Sustained Activation: {result['components']['sustained']:.2f} / 10.0 β | |
| β β’ Co-occurrence Pattern: {result['components']['co_occurrence']:.2f} / 8.0 β | |
| β β’ Smile Masking Effect: {result['components']['social_smile_masking']:.2f} (adds stress if present) β | |
| β β’ Duchenne Smile Bonus: -{result['components']['duchenne_smile']*15:.2f} (reduces stress) β | |
| β β | |
| β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£ | |
| β POSITIVE AFFECT INDICATORS: β | |
| β β’ AU06 (Cheek Raise): {result['activation_percentages']['AU06']:5.1f}% active β | |
| β β’ AU12 (Lip Pull): {result['activation_percentages']['AU12']:5.1f}% active β | |
| β β’ AU14 (Dimpler): {result['activation_percentages']['AU14']:5.1f}% active β | |
| β β’ Duchenne Smile Rate: {result['components']['duchenne_smile']*100:.1f}% β | |
| β β | |
| β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£ | |
| β RESEARCH BASIS: β | |
| β Weights based on Facial Action Coding System (FACS) research: β | |
| β β’ AU04 highest weight (Ekman & Friesen, 1978) - primary anger/stress indicator β | |
| β β’ AU06+AU12 combination identifies genuine happiness (Duchenne marker) β | |
| β β’ Sustained activation and co-occurrence patterns enhance stress detection accuracy β | |
| β β’ Temporal windowing allows detection of acute stress episodes vs chronic patterns β | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| """ | |
| ax10.text(0.05, 0.5, summary_text, fontsize=8, family='monospace', | |
| verticalalignment='center', | |
| bbox=dict(boxstyle='round', facecolor='lightgray', alpha=0.2)) | |
| plt.tight_layout() | |
| return fig, result | |
| # ==================== MAIN EXECUTION ==================== | |
| if __name__ == "__main__": | |
| print("\n" + "="*70) | |
| print(" COMPLETE 10-AU STRESS DETECTION SYSTEM") | |
| print(" Based on Facial Action Coding System (FACS)") | |
| print(" Research Guide: Prof. Anup Nandy") | |
| print("="*70) | |
| print("\n Action Units Detected:") | |
| print(" STRESS: AU01, AU04, AU07, AU17, AU23, AU24") | |
| print(" POSITIVE: AU06, AU12, AU14, AU26") | |
| print("\n Press Enter to start 15-second recording...") | |
| input() | |
| df = run_detection_session(duration_seconds=15, save_data=True) | |
| print("\n" + "="*70) | |
| print(" Generating comprehensive analysis...") | |
| print("="*70 + "\n") | |
| fig, result = plot_comprehensive_analysis(df) | |
| print(f"\n{result['color']} FINAL ASSESSMENT: {result['classification']}") | |
| print(f" Stress Score: {result['stress_score']:.1f}/100") | |
| print(f"\n Data saved with {len(df)} frames") | |
| print(f" Total features per frame: {len(df.columns) - 1}") | |
| print("\n" + "="*70) | |
| plt.show() | |
| print("\nβ Analysis complete!") |