import numpy as np EMOTION_LABELS = ["angry", "disgust", "fear", "happy", "sad", "surprise", "neutral"] # Keep dummy function to avoid breaking app.py startup def get_cnn_model(): return None # State variables for calibration and smoothing _baseline_metrics = None _calibration_frames = 0 _CALIBRATION_MAX = 5 _metric_buffer = [] # rolling buffer of length 3 def reset_calibration(): global _baseline_metrics, _calibration_frames, _metric_buffer _baseline_metrics = None _calibration_frames = 0 _metric_buffer = [] def predict_emotion(frame_rgb, landmarks=None): """ Main emotion prediction entry point. Bypasses CNN completely and uses high-precision Landmark-Ratio Logic. """ return predict_emotion_from_landmarks(landmarks) def extract_metrics(pts): # 1. Mouth Width (61 to 291) mouth_width = np.linalg.norm(pts[61] - pts[291]) # 2. Eye Distance (Outer corners: 33 to 263) eye_dist = np.linalg.norm(pts[33] - pts[263]) # 3. Teeth visibility / Jaw Drop (Inner lips: 13 to 14) inner_lip_dist = np.linalg.norm(pts[13] - pts[14]) # 4. Inner Brows distance (70 to 300) inner_brow_dist = np.linalg.norm(pts[70] - pts[300]) # 5. Nose tip to lip corners (Vertical distance) -> Frown # Nose tip: 4. Lip corners: 61, 291 nose_to_lip_y = ((pts[61][1] + pts[291][1]) / 2.0) - pts[4][1] # 6. Eye Opening (Vertical) # Left eye: 159 to 145, Right eye: 386 to 374 left_eye_open = np.linalg.norm(pts[159] - pts[145]) right_eye_open = np.linalg.norm(pts[386] - pts[374]) eye_opening = (left_eye_open + right_eye_open) / 2.0 # 7. Brow to Eye Vertical Distance (Brows Rise/Lower) # Eye centers: 159, 386. Inner brows: 70, 300 brow_to_eye_y = ((pts[159][1] + pts[386][1]) / 2.0) - ((pts[70][1] + pts[300][1]) / 2.0) # 8. Upward Curve (Mouth center Y vs Lip corners Y) -> Smile mouth_center_y = (pts[13][1] + pts[14][1]) / 2.0 lip_corner_avg_y = (pts[61][1] + pts[291][1]) / 2.0 upward_curve = mouth_center_y - lip_corner_avg_y # positive means smile # 9. Nose bridge to upper lip (Nose Wrinkler) # Nose bridge: 6, Upper lip: 13 nose_to_upper_lip = np.linalg.norm(pts[6] - pts[13]) return { "mouth_width": float(mouth_width), "eye_dist": float(eye_dist), "inner_lip_dist": float(inner_lip_dist), "inner_brow_dist": float(inner_brow_dist), "nose_to_lip_y": float(nose_to_lip_y), "eye_opening": float(eye_opening), "brow_to_eye_y": float(brow_to_eye_y), "upward_curve": float(upward_curve), "nose_to_upper_lip": float(nose_to_upper_lip) } def predict_emotion_from_landmarks(landmarks): global _baseline_metrics, _calibration_frames, _metric_buffer emotion_engagement = { "Happy": 90, "Surprise": 80, "Neutral": 50, "Angry": 30, "Fear": 25, "Sad": 15, "Disgust": 10 } default_response = { "emotion": "Neutral", "confidence": 50, "probabilities": {k.capitalize(): (50 if k=="neutral" else 0) for k in EMOTION_LABELS}, "engagement_score": 50, "provider": "Landmark-Ratio Logic" } if not landmarks or len(landmarks) < 400: return default_response try: if isinstance(landmarks[0], dict): pts = np.array([[l['x'], l['y']] for l in landmarks]) else: pts = np.array([[l.x, l.y] for l in landmarks]) current_metrics = extract_metrics(pts) # 3-Frame Rolling Buffer _metric_buffer.append(current_metrics) if len(_metric_buffer) > 3: _metric_buffer.pop(0) # Compute smoothed metrics c = {} for key in current_metrics.keys(): c[key] = sum(m[key] for m in _metric_buffer) / len(_metric_buffer) # Calibration Phase if _calibration_frames < _CALIBRATION_MAX: if _baseline_metrics is None: _baseline_metrics = c.copy() else: for k in c: _baseline_metrics[k] += c[k] _calibration_frames += 1 if _calibration_frames == _CALIBRATION_MAX: for k in _baseline_metrics: _baseline_metrics[k] /= _CALIBRATION_MAX return default_response b = _baseline_metrics # Scoring engine with highly sensitive AU thresholding (90-100% confidence targets) scores = {k.capitalize(): 0.0 for k in EMOTION_LABELS} # Helper for % difference def pct_change(curr, base): return (curr - base) / max(base, 0.001) # 1. Happy (Mouth width / Eye dist ratio increases) mouth_eye_ratio_c = c["mouth_width"] / max(c["eye_dist"], 0.001) mouth_eye_ratio_b = b["mouth_width"] / max(b["eye_dist"], 0.001) happy_ratio_inc = pct_change(mouth_eye_ratio_c, mouth_eye_ratio_b) if happy_ratio_inc > 0.08: # 8% increase triggers Happy intensity = 90.0 + ((happy_ratio_inc - 0.08) / 0.10) * 10.0 if c["inner_lip_dist"] > b["inner_lip_dist"] * 1.5 and c["inner_lip_dist"] > 0.005: intensity += 5.0 # Teeth bonus scores["Happy"] = min(intensity, 100.0) # 2. Sad (Inner brows rise, Lip corners drop) brow_rise_inc = pct_change(c["brow_to_eye_y"], b["brow_to_eye_y"]) lip_drop_inc = pct_change(c["nose_to_lip_y"], b["nose_to_lip_y"]) if brow_rise_inc > 0.02 and lip_drop_inc > 0.02: intensity = 90.0 + ((lip_drop_inc - 0.02) / 0.05) * 10.0 scores["Sad"] = min(intensity, 100.0) # 3. Surprise (Eye opening increases heavily, Jaw drops) eye_open_inc = pct_change(c["eye_opening"], b["eye_opening"]) jaw_drop_inc = c["inner_lip_dist"] - b["inner_lip_dist"] if eye_open_inc > 0.15 and jaw_drop_inc > 0.01: intensity = 90.0 + ((eye_open_inc - 0.15) / 0.20) * 10.0 scores["Surprise"] = min(intensity, 100.0) # 4. Fear/Shocked (Brows rise & pull together, Lip stretch, No smile) brow_dist_dec = pct_change(b["inner_brow_dist"], c["inner_brow_dist"]) # Positive means it decreased lip_stretch_inc = pct_change(c["mouth_width"], b["mouth_width"]) if brow_rise_inc > 0.01 and brow_dist_dec > 0.02 and lip_stretch_inc > 0.03 and c["upward_curve"] < b["upward_curve"] + 0.01: intensity = 90.0 + ((lip_stretch_inc - 0.03) / 0.05) * 10.0 scores["Fear"] = min(intensity, 100.0) # 5. Angry (Brows lower & pull together heavily, Lip tightening) brow_lower_dec = pct_change(b["brow_to_eye_y"], c["brow_to_eye_y"]) # Positive means brows lowered lip_tight_dec = pct_change(b["inner_lip_dist"], c["inner_lip_dist"]) if brow_dist_dec > 0.10 and brow_lower_dec > 0.02: intensity = 90.0 + ((brow_dist_dec - 0.10) / 0.10) * 10.0 scores["Angry"] = min(intensity, 100.0) # 6. Disgust (Nose wrinkler / Upper lip raiser) nose_short_dec = pct_change(b["nose_to_upper_lip"], c["nose_to_upper_lip"]) # Positive means distance shortened if nose_short_dec > 0.02: intensity = 90.0 + ((nose_short_dec - 0.02) / 0.05) * 10.0 scores["Disgust"] = min(intensity, 100.0) # 7. Neutral (Fallback if no significant variance) max_variance = 0.0 for k in c: var = abs(c[k] - b[k]) / max(b[k], 0.001) if var > max_variance: max_variance = var if max_variance <= 0.03 or sum(scores.values()) == 0: scores["Neutral"] = 100.0 # Post-processing to find best emotion best_emotion = max(scores, key=scores.get) max_score = scores[best_emotion] # If no strict threshold passed and not neutral, fallback to Neutral if max_score < 50.0: best_emotion = "Neutral" scores["Neutral"] = 100.0 max_score = 100.0 # Normalize probabilities total = sum(scores.values()) if total > 0: probs = {k: round((v / total) * 100, 1) for k, v in scores.items()} else: probs = {k.capitalize(): (100.0 if k=="neutral" else 0.0) for k in EMOTION_LABELS} confidence = round(min(max_score, 100.0), 1) return { "emotion": best_emotion, "confidence": confidence, "probabilities": probs, "engagement_score": emotion_engagement.get(best_emotion, 50), "provider": "Landmark-Ratio Logic" } except Exception as e: print(f"Landmark emotion error: {e}") return default_response