Skip to main content

oxihuman_morph/
emotion_system.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Emotion-driven facial expression mapping.
5//!
6//! Maps emotional states (Ekman's basic emotions + neutral) to blended collections
7//! of facial morph target weights, with per-emotion intensity control and
8//! valence-arousal space support.
9
10#![allow(dead_code)]
11
12use std::collections::HashMap;
13
14// ---------------------------------------------------------------------------
15// Emotion
16// ---------------------------------------------------------------------------
17
18/// Primary emotions (Ekman's basic emotions + neutral).
19#[derive(Clone, Debug, PartialEq, Eq, Hash)]
20pub enum Emotion {
21    Neutral,
22    Happy,
23    Sad,
24    Angry,
25    Surprised,
26    Fearful,
27    Disgusted,
28    Contempt,
29}
30
31impl Emotion {
32    /// All emotion variants in canonical order.
33    pub fn all() -> &'static [Emotion] {
34        use Emotion::*;
35        &[
36            Neutral, Happy, Sad, Angry, Surprised, Fearful, Disgusted, Contempt,
37        ]
38    }
39
40    /// Human-readable name for the emotion.
41    pub fn name(&self) -> &'static str {
42        match self {
43            Emotion::Neutral => "neutral",
44            Emotion::Happy => "happy",
45            Emotion::Sad => "sad",
46            Emotion::Angry => "angry",
47            Emotion::Surprised => "surprised",
48            Emotion::Fearful => "fearful",
49            Emotion::Disgusted => "disgusted",
50            Emotion::Contempt => "contempt",
51        }
52    }
53
54    /// Valence: positive = pleasant, negative = unpleasant, 0 = neutral.
55    pub fn valence(&self) -> f32 {
56        match self {
57            Emotion::Neutral => 0.0,
58            Emotion::Happy => 1.0,
59            Emotion::Sad => -1.0,
60            Emotion::Angry => -0.8,
61            Emotion::Surprised => 0.2,
62            Emotion::Fearful => -0.5,
63            Emotion::Disgusted => -0.7,
64            Emotion::Contempt => -0.6,
65        }
66    }
67
68    /// Arousal: high = excited/intense, low = calm/passive.
69    pub fn arousal(&self) -> f32 {
70        match self {
71            Emotion::Neutral => 0.0,
72            Emotion::Happy => 0.7,
73            Emotion::Sad => -0.4,
74            Emotion::Angry => 0.9,
75            Emotion::Surprised => 0.8,
76            Emotion::Fearful => 0.7,
77            Emotion::Disgusted => 0.3,
78            Emotion::Contempt => 0.2,
79        }
80    }
81}
82
83// ---------------------------------------------------------------------------
84// EmotionExpression
85// ---------------------------------------------------------------------------
86
87/// A morph weight map for one emotion at a given intensity.
88pub struct EmotionExpression {
89    /// The emotion this expression represents.
90    pub emotion: Emotion,
91    /// Global intensity scalar (0..=1).
92    pub intensity: f32,
93    /// Morph target name → base weight (before intensity scaling).
94    pub weights: HashMap<String, f32>,
95}
96
97impl EmotionExpression {
98    /// Create a new expression with intensity 1.0 and no morph weights.
99    pub fn new(emotion: Emotion) -> Self {
100        Self {
101            emotion,
102            intensity: 1.0,
103            weights: HashMap::new(),
104        }
105    }
106
107    /// Builder: add or replace a morph target weight.
108    pub fn with_weight(mut self, morph: impl Into<String>, weight: f32) -> Self {
109        self.weights.insert(morph.into(), weight);
110        self
111    }
112
113    /// Builder: set global intensity.
114    pub fn with_intensity(mut self, intensity: f32) -> Self {
115        self.intensity = intensity.clamp(0.0, 1.0);
116        self
117    }
118
119    /// Return the effective weights: each base weight multiplied by intensity.
120    pub fn effective_weights(&self) -> HashMap<String, f32> {
121        self.weights
122            .iter()
123            .map(|(k, &v)| (k.clone(), v * self.intensity))
124            .collect()
125    }
126}
127
128// ---------------------------------------------------------------------------
129// EmotionBlend
130// ---------------------------------------------------------------------------
131
132/// A blend of multiple emotions, each with its own blend weight.
133pub struct EmotionBlend {
134    /// Emotion → blend weight (sum should be ≤ 1.0).
135    pub components: HashMap<Emotion, f32>,
136}
137
138impl EmotionBlend {
139    /// Create an empty blend.
140    pub fn new() -> Self {
141        Self {
142            components: HashMap::new(),
143        }
144    }
145
146    /// Create a blend containing a single emotion at the given weight.
147    pub fn single(emotion: Emotion, weight: f32) -> Self {
148        let mut blend = Self::new();
149        blend.components.insert(emotion, weight.clamp(0.0, 1.0));
150        blend
151    }
152
153    /// Add or accumulate a blend weight for an emotion.
154    pub fn add(&mut self, emotion: Emotion, weight: f32) {
155        let entry = self.components.entry(emotion).or_insert(0.0);
156        *entry = (*entry + weight).clamp(0.0, 1.0);
157    }
158
159    /// Scale all weights so they sum to 1.0. No-op if sum is zero.
160    pub fn normalize(&mut self) {
161        let sum: f32 = self.components.values().copied().sum();
162        if sum > f32::EPSILON {
163            for v in self.components.values_mut() {
164                *v /= sum;
165            }
166        }
167    }
168
169    /// Return the emotion with the highest blend weight, if any.
170    pub fn dominant(&self) -> Option<&Emotion> {
171        self.components
172            .iter()
173            .filter(|(_, &w)| w > 0.0)
174            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
175            .map(|(e, _)| e)
176    }
177
178    /// Returns `true` when all blend weights are below 0.05 (effectively neutral).
179    pub fn is_neutral(&self) -> bool {
180        self.components.values().all(|&w| w < 0.05)
181    }
182}
183
184impl Default for EmotionBlend {
185    fn default() -> Self {
186        Self::new()
187    }
188}
189
190// ---------------------------------------------------------------------------
191// EmotionSystem
192// ---------------------------------------------------------------------------
193
194/// Maps emotions to morph target weights and evaluates blended expressions.
195pub struct EmotionSystem {
196    expressions: HashMap<Emotion, EmotionExpression>,
197}
198
199impl EmotionSystem {
200    /// Create an empty emotion system.
201    pub fn new() -> Self {
202        Self {
203            expressions: HashMap::new(),
204        }
205    }
206
207    /// Register or replace an expression for its emotion.
208    pub fn add_expression(&mut self, expr: EmotionExpression) {
209        self.expressions.insert(expr.emotion.clone(), expr);
210    }
211
212    /// Look up the expression for the given emotion.
213    pub fn get_expression(&self, emotion: &Emotion) -> Option<&EmotionExpression> {
214        self.expressions.get(emotion)
215    }
216
217    /// Evaluate blended morph weights for a given emotion blend.
218    ///
219    /// For each emotion component with weight > 0, the expression weights are
220    /// scaled by `component_weight * expression.intensity`, then additively blended
221    /// and clamped to [0, 1].
222    pub fn evaluate(&self, blend: &EmotionBlend) -> HashMap<String, f32> {
223        let mut result: HashMap<String, f32> = HashMap::new();
224
225        for (emotion, &blend_weight) in &blend.components {
226            if blend_weight <= 0.0 {
227                continue;
228            }
229            if let Some(expr) = self.expressions.get(emotion) {
230                for (morph, &base_w) in &expr.weights {
231                    let contribution = base_w * expr.intensity * blend_weight;
232                    let entry = result.entry(morph.clone()).or_insert(0.0);
233                    *entry += contribution;
234                }
235            }
236        }
237
238        // Clamp all final values to [0, 1].
239        for v in result.values_mut() {
240            *v = v.clamp(0.0, 1.0);
241        }
242        result
243    }
244
245    /// Evaluate a single emotion at the given intensity (overrides expression intensity).
246    pub fn evaluate_single(&self, emotion: &Emotion, intensity: f32) -> HashMap<String, f32> {
247        let intensity = intensity.clamp(0.0, 1.0);
248        match self.expressions.get(emotion) {
249            None => HashMap::new(),
250            Some(expr) => expr
251                .weights
252                .iter()
253                .map(|(k, &v)| (k.clone(), (v * intensity).clamp(0.0, 1.0)))
254                .collect(),
255        }
256    }
257
258    /// Convert a valence-arousal coordinate to an emotion blend using inverse-distance
259    /// weighting (IDW) over the 3 nearest emotions in V-A space.
260    pub fn from_valence_arousal(&self, valence: f32, arousal: f32) -> EmotionBlend {
261        let k = 3usize; // number of nearest neighbours
262
263        // Compute squared Euclidean distances to each emotion's V-A position.
264        let mut distances: Vec<(Emotion, f32)> = Emotion::all()
265            .iter()
266            .map(|e| {
267                let dv = e.valence() - valence;
268                let da = e.arousal() - arousal;
269                let dist = (dv * dv + da * da).sqrt();
270                (e.clone(), dist)
271            })
272            .collect();
273
274        // Sort by distance ascending.
275        distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
276
277        // If the query is exactly on an emotion, return it with weight 1.
278        if distances[0].1 < f32::EPSILON {
279            return EmotionBlend::single(distances[0].0.clone(), 1.0);
280        }
281
282        // Take the k nearest and compute IDW weights.
283        let nearest = &distances[..k.min(distances.len())];
284        let inv_dist_sum: f32 = nearest.iter().map(|(_, d)| 1.0 / d).sum();
285
286        let mut blend = EmotionBlend::new();
287        for (emotion, dist) in nearest {
288            let w = (1.0 / dist) / inv_dist_sum;
289            blend.components.insert(emotion.clone(), w);
290        }
291        blend
292    }
293}
294
295impl Default for EmotionSystem {
296    fn default() -> Self {
297        Self::new()
298    }
299}
300
301// ---------------------------------------------------------------------------
302// default_emotion_system
303// ---------------------------------------------------------------------------
304
305/// Build a default emotion system with typical MakeHuman-style morph names.
306pub fn default_emotion_system() -> EmotionSystem {
307    let mut sys = EmotionSystem::new();
308
309    // Neutral — baseline, no morph contribution.
310    sys.add_expression(EmotionExpression::new(Emotion::Neutral));
311
312    // Happy
313    sys.add_expression(
314        EmotionExpression::new(Emotion::Happy)
315            .with_weight("smile_mouth", 0.8)
316            .with_weight("cheeks_raise", 0.6)
317            .with_weight("eyes_squint", 0.3),
318    );
319
320    // Sad
321    sys.add_expression(
322        EmotionExpression::new(Emotion::Sad)
323            .with_weight("mouth_frown", 0.7)
324            .with_weight("brow_inner_up", 0.5)
325            .with_weight("eyes_widen", 0.2),
326    );
327
328    // Angry
329    sys.add_expression(
330        EmotionExpression::new(Emotion::Angry)
331            .with_weight("brow_down", 0.8)
332            .with_weight("nose_scrunch", 0.5)
333            .with_weight("lip_press", 0.6),
334    );
335
336    // Surprised
337    sys.add_expression(
338        EmotionExpression::new(Emotion::Surprised)
339            .with_weight("eyes_widen", 0.9)
340            .with_weight("brow_raise", 0.8)
341            .with_weight("jaw_drop", 0.6),
342    );
343
344    // Fearful
345    sys.add_expression(
346        EmotionExpression::new(Emotion::Fearful)
347            .with_weight("eyes_widen", 0.8)
348            .with_weight("brow_raise", 0.5)
349            .with_weight("mouth_open", 0.4)
350            .with_weight("lip_stretch", 0.5),
351    );
352
353    // Disgusted
354    sys.add_expression(
355        EmotionExpression::new(Emotion::Disgusted)
356            .with_weight("nose_scrunch", 0.8)
357            .with_weight("upper_lip_raise", 0.7)
358            .with_weight("brow_down", 0.3),
359    );
360
361    // Contempt
362    sys.add_expression(
363        EmotionExpression::new(Emotion::Contempt)
364            .with_weight("lip_corner_pull_r", 0.6)
365            .with_weight("cheek_raise_r", 0.3)
366            .with_weight("brow_down", 0.2),
367    );
368
369    sys
370}
371
372// ---------------------------------------------------------------------------
373// lerp_emotion_blend
374// ---------------------------------------------------------------------------
375
376/// Linearly interpolate between two emotion blends.
377///
378/// The union of keys from both blends is used; missing keys are treated as 0.
379/// `t = 0` returns a clone of `a`; `t = 1` returns a clone of `b`.
380pub fn lerp_emotion_blend(a: &EmotionBlend, b: &EmotionBlend, t: f32) -> EmotionBlend {
381    let t = t.clamp(0.0, 1.0);
382    let mut result = EmotionBlend::new();
383
384    // Collect all emotions from both blends.
385    let mut all_emotions: Vec<Emotion> = a.components.keys().cloned().collect();
386    for e in b.components.keys() {
387        if !all_emotions.contains(e) {
388            all_emotions.push(e.clone());
389        }
390    }
391
392    for emotion in all_emotions {
393        let wa = a.components.get(&emotion).copied().unwrap_or(0.0);
394        let wb = b.components.get(&emotion).copied().unwrap_or(0.0);
395        let w = wa + (wb - wa) * t;
396        if w > 0.0 {
397            result.components.insert(emotion, w);
398        }
399    }
400    result
401}
402
403// ---------------------------------------------------------------------------
404// Tests
405// ---------------------------------------------------------------------------
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[test]
412    fn test_emotion_all() {
413        let all = Emotion::all();
414        assert_eq!(all.len(), 8);
415        assert!(all.contains(&Emotion::Neutral));
416        assert!(all.contains(&Emotion::Happy));
417        assert!(all.contains(&Emotion::Sad));
418        assert!(all.contains(&Emotion::Angry));
419        assert!(all.contains(&Emotion::Surprised));
420        assert!(all.contains(&Emotion::Fearful));
421        assert!(all.contains(&Emotion::Disgusted));
422        assert!(all.contains(&Emotion::Contempt));
423    }
424
425    #[test]
426    fn test_emotion_names() {
427        assert_eq!(Emotion::Neutral.name(), "neutral");
428        assert_eq!(Emotion::Happy.name(), "happy");
429        assert_eq!(Emotion::Sad.name(), "sad");
430        assert_eq!(Emotion::Angry.name(), "angry");
431        assert_eq!(Emotion::Surprised.name(), "surprised");
432        assert_eq!(Emotion::Fearful.name(), "fearful");
433        assert_eq!(Emotion::Disgusted.name(), "disgusted");
434        assert_eq!(Emotion::Contempt.name(), "contempt");
435    }
436
437    #[test]
438    fn test_emotion_valence_arousal() {
439        assert!((Emotion::Neutral.valence() - 0.0).abs() < f32::EPSILON);
440        assert!((Emotion::Neutral.arousal() - 0.0).abs() < f32::EPSILON);
441        assert!((Emotion::Happy.valence() - 1.0).abs() < f32::EPSILON);
442        assert!((Emotion::Happy.arousal() - 0.7).abs() < f32::EPSILON);
443        assert!((Emotion::Sad.valence() - (-1.0)).abs() < f32::EPSILON);
444        assert!((Emotion::Sad.arousal() - (-0.4)).abs() < f32::EPSILON);
445        assert!((Emotion::Angry.valence() - (-0.8)).abs() < f32::EPSILON);
446        assert!((Emotion::Angry.arousal() - 0.9).abs() < f32::EPSILON);
447        assert!((Emotion::Contempt.valence() - (-0.6)).abs() < f32::EPSILON);
448        assert!((Emotion::Contempt.arousal() - 0.2).abs() < f32::EPSILON);
449    }
450
451    #[test]
452    fn test_expression_effective_weights() {
453        let expr = EmotionExpression::new(Emotion::Happy)
454            .with_weight("smile_mouth", 0.8)
455            .with_weight("cheeks_raise", 0.6)
456            .with_intensity(0.5);
457
458        let eff = expr.effective_weights();
459        let smile = eff["smile_mouth"];
460        let cheeks = eff["cheeks_raise"];
461        assert!((smile - 0.4).abs() < 1e-5, "smile: {smile}");
462        assert!((cheeks - 0.3).abs() < 1e-5, "cheeks: {cheeks}");
463    }
464
465    #[test]
466    fn test_blend_single() {
467        let blend = EmotionBlend::single(Emotion::Happy, 0.7);
468        assert_eq!(blend.components.len(), 1);
469        assert!((blend.components[&Emotion::Happy] - 0.7).abs() < 1e-5);
470    }
471
472    #[test]
473    fn test_blend_add() {
474        let mut blend = EmotionBlend::new();
475        blend.add(Emotion::Happy, 0.4);
476        blend.add(Emotion::Sad, 0.3);
477        blend.add(Emotion::Happy, 0.2); // accumulates
478        assert!((blend.components[&Emotion::Happy] - 0.6).abs() < 1e-5);
479        assert!((blend.components[&Emotion::Sad] - 0.3).abs() < 1e-5);
480    }
481
482    #[test]
483    fn test_blend_normalize() {
484        let mut blend = EmotionBlend::new();
485        blend.components.insert(Emotion::Happy, 0.4);
486        blend.components.insert(Emotion::Sad, 0.6);
487        blend.normalize();
488        let sum: f32 = blend.components.values().sum();
489        assert!((sum - 1.0).abs() < 1e-5, "sum after normalize: {sum}");
490    }
491
492    #[test]
493    fn test_blend_dominant() {
494        let mut blend = EmotionBlend::new();
495        blend.components.insert(Emotion::Happy, 0.3);
496        blend.components.insert(Emotion::Angry, 0.7);
497        blend.components.insert(Emotion::Sad, 0.1);
498        let dom = blend.dominant().expect("should have dominant");
499        assert_eq!(*dom, Emotion::Angry);
500    }
501
502    #[test]
503    fn test_blend_is_neutral() {
504        let mut blend = EmotionBlend::new();
505        assert!(blend.is_neutral(), "empty blend is neutral");
506
507        blend.components.insert(Emotion::Happy, 0.04);
508        assert!(blend.is_neutral(), "all weights < 0.05 → neutral");
509
510        blend.components.insert(Emotion::Sad, 0.06);
511        assert!(!blend.is_neutral(), "weight 0.06 → not neutral");
512    }
513
514    #[test]
515    fn test_system_evaluate_single() {
516        let sys = default_emotion_system();
517        let weights = sys.evaluate_single(&Emotion::Happy, 1.0);
518        assert!(
519            weights.contains_key("smile_mouth"),
520            "should contain smile_mouth"
521        );
522        let smile = weights["smile_mouth"];
523        assert!(
524            (smile - 0.8).abs() < 1e-5,
525            "smile_mouth at full intensity: {smile}"
526        );
527
528        let half = sys.evaluate_single(&Emotion::Happy, 0.5);
529        let smile_half = half["smile_mouth"];
530        assert!(
531            (smile_half - 0.4).abs() < 1e-5,
532            "smile_mouth at half intensity: {smile_half}"
533        );
534    }
535
536    #[test]
537    fn test_system_evaluate_blend() {
538        let sys = default_emotion_system();
539        let mut blend = EmotionBlend::new();
540        blend.components.insert(Emotion::Happy, 1.0);
541        let weights = sys.evaluate(&blend);
542        assert!(weights.contains_key("smile_mouth"));
543        let smile = weights["smile_mouth"];
544        assert!((smile - 0.8).abs() < 1e-5, "blended smile_mouth: {smile}");
545    }
546
547    #[test]
548    fn test_from_valence_arousal() {
549        let sys = default_emotion_system();
550
551        // Query exactly at Happy's V-A position (1.0, 0.7).
552        let blend = sys.from_valence_arousal(1.0, 0.7);
553        let dom = blend.dominant().expect("should have dominant emotion");
554        assert_eq!(*dom, Emotion::Happy, "nearest to (1,0.7) should be Happy");
555
556        // Query at Angry's V-A position (-0.8, 0.9).
557        let blend_angry = sys.from_valence_arousal(-0.8, 0.9);
558        let dom_angry = blend_angry.dominant().expect("should have dominant");
559        assert_eq!(*dom_angry, Emotion::Angry);
560    }
561
562    #[test]
563    fn test_default_emotion_system() {
564        let sys = default_emotion_system();
565        for emotion in Emotion::all() {
566            assert!(
567                sys.get_expression(emotion).is_some(),
568                "default system should have expression for {}",
569                emotion.name()
570            );
571        }
572        // Happy should have at least 3 morph targets.
573        let happy_expr = sys.get_expression(&Emotion::Happy).expect("should succeed");
574        assert!(
575            happy_expr.weights.len() >= 3,
576            "Happy should have at least 3 morph weights"
577        );
578    }
579
580    #[test]
581    fn test_lerp_emotion_blend() {
582        let a = EmotionBlend::single(Emotion::Happy, 1.0);
583        let b = EmotionBlend::single(Emotion::Sad, 1.0);
584
585        let mid = lerp_emotion_blend(&a, &b, 0.5);
586        let happy_w = mid.components.get(&Emotion::Happy).copied().unwrap_or(0.0);
587        let sad_w = mid.components.get(&Emotion::Sad).copied().unwrap_or(0.0);
588        assert!((happy_w - 0.5).abs() < 1e-5, "happy at t=0.5: {happy_w}");
589        assert!((sad_w - 0.5).abs() < 1e-5, "sad at t=0.5: {sad_w}");
590
591        // t=0 should be identical to a.
592        let at_zero = lerp_emotion_blend(&a, &b, 0.0);
593        assert!((at_zero.components[&Emotion::Happy] - 1.0).abs() < 1e-5);
594        assert!(!at_zero.components.contains_key(&Emotion::Sad));
595
596        // t=1 should be identical to b.
597        let at_one = lerp_emotion_blend(&a, &b, 1.0);
598        assert!(!at_one.components.contains_key(&Emotion::Happy));
599        assert!((at_one.components[&Emotion::Sad] - 1.0).abs() < 1e-5);
600    }
601}