Skip to main content

oxihuman_morph/
facs.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Facial Action Coding System (FACS) implementation.
5//!
6//! Maps the standard FACS Action Units (AUs) to morph-target weights used in
7//! the OxiHuman pipeline.  The FACS system was developed by Paul Ekman and
8//! Wallace Friesen and describes individual facial muscle activations with a
9//! set of numbered Action Units.
10
11#![allow(dead_code)]
12
13use std::collections::HashMap;
14
15// ---------------------------------------------------------------------------
16// ActionUnit
17// ---------------------------------------------------------------------------
18
19/// A FACS Action Unit representing an individual facial muscle activation.
20#[derive(Clone, Debug, PartialEq, Eq, Hash)]
21pub enum ActionUnit {
22    // Upper face
23    AU1, // Inner Brow Raise
24    AU2, // Outer Brow Raise
25    AU4, // Brow Lowerer
26    AU5, // Upper Lid Raiser
27    AU6, // Cheek Raiser
28    AU7, // Lid Tightener
29    // Nose
30    AU9,  // Nose Wrinkler
31    AU10, // Upper Lip Raiser
32    // Mouth
33    AU11, // Nasolabial Deepener
34    AU12, // Lip Corner Puller (Smile)
35    AU13, // Cheek Puffer
36    AU14, // Dimpler
37    AU15, // Lip Corner Depressor
38    AU16, // Lower Lip Depressor
39    AU17, // Chin Raiser
40    AU18, // Lip Puckerer
41    AU20, // Lip Stretcher
42    AU22, // Lip Funneler
43    AU23, // Lip Tightener
44    AU24, // Lip Pressor
45    AU25, // Lips Part
46    AU26, // Jaw Drop
47    AU27, // Mouth Stretch
48    AU28, // Lip Suck
49    // Eye
50    AU41, // Lid Droop
51    AU42, // Slit
52    AU43, // Eyes Closed
53    AU44, // Squint
54    AU45, // Blink
55    AU46, // Wink
56}
57
58impl ActionUnit {
59    /// Return the canonical FACS number for this Action Unit.
60    pub fn number(&self) -> u32 {
61        match self {
62            ActionUnit::AU1 => 1,
63            ActionUnit::AU2 => 2,
64            ActionUnit::AU4 => 4,
65            ActionUnit::AU5 => 5,
66            ActionUnit::AU6 => 6,
67            ActionUnit::AU7 => 7,
68            ActionUnit::AU9 => 9,
69            ActionUnit::AU10 => 10,
70            ActionUnit::AU11 => 11,
71            ActionUnit::AU12 => 12,
72            ActionUnit::AU13 => 13,
73            ActionUnit::AU14 => 14,
74            ActionUnit::AU15 => 15,
75            ActionUnit::AU16 => 16,
76            ActionUnit::AU17 => 17,
77            ActionUnit::AU18 => 18,
78            ActionUnit::AU20 => 20,
79            ActionUnit::AU22 => 22,
80            ActionUnit::AU23 => 23,
81            ActionUnit::AU24 => 24,
82            ActionUnit::AU25 => 25,
83            ActionUnit::AU26 => 26,
84            ActionUnit::AU27 => 27,
85            ActionUnit::AU28 => 28,
86            ActionUnit::AU41 => 41,
87            ActionUnit::AU42 => 42,
88            ActionUnit::AU43 => 43,
89            ActionUnit::AU44 => 44,
90            ActionUnit::AU45 => 45,
91            ActionUnit::AU46 => 46,
92        }
93    }
94
95    /// Short human-readable name for the AU.
96    pub fn name(&self) -> &'static str {
97        match self {
98            ActionUnit::AU1 => "Inner Brow Raise",
99            ActionUnit::AU2 => "Outer Brow Raise",
100            ActionUnit::AU4 => "Brow Lowerer",
101            ActionUnit::AU5 => "Upper Lid Raiser",
102            ActionUnit::AU6 => "Cheek Raiser",
103            ActionUnit::AU7 => "Lid Tightener",
104            ActionUnit::AU9 => "Nose Wrinkler",
105            ActionUnit::AU10 => "Upper Lip Raiser",
106            ActionUnit::AU11 => "Nasolabial Deepener",
107            ActionUnit::AU12 => "Lip Corner Puller",
108            ActionUnit::AU13 => "Cheek Puffer",
109            ActionUnit::AU14 => "Dimpler",
110            ActionUnit::AU15 => "Lip Corner Depressor",
111            ActionUnit::AU16 => "Lower Lip Depressor",
112            ActionUnit::AU17 => "Chin Raiser",
113            ActionUnit::AU18 => "Lip Puckerer",
114            ActionUnit::AU20 => "Lip Stretcher",
115            ActionUnit::AU22 => "Lip Funneler",
116            ActionUnit::AU23 => "Lip Tightener",
117            ActionUnit::AU24 => "Lip Pressor",
118            ActionUnit::AU25 => "Lips Part",
119            ActionUnit::AU26 => "Jaw Drop",
120            ActionUnit::AU27 => "Mouth Stretch",
121            ActionUnit::AU28 => "Lip Suck",
122            ActionUnit::AU41 => "Lid Droop",
123            ActionUnit::AU42 => "Slit",
124            ActionUnit::AU43 => "Eyes Closed",
125            ActionUnit::AU44 => "Squint",
126            ActionUnit::AU45 => "Blink",
127            ActionUnit::AU46 => "Wink",
128        }
129    }
130
131    /// Longer description of the muscle action.
132    pub fn description(&self) -> &'static str {
133        match self {
134            ActionUnit::AU1 => "Medial frontalis raises the inner portion of the brow",
135            ActionUnit::AU2 => "Lateral frontalis raises the outer portion of the brow",
136            ActionUnit::AU4 => "Corrugator and depressor supercilii lower the brows",
137            ActionUnit::AU5 => "Levator palpebrae superioris raises the upper eyelid",
138            ActionUnit::AU6 => "Orbicularis oculi (orbital) raises the cheek",
139            ActionUnit::AU7 => "Orbicularis oculi (palpebral) tightens the lower lid",
140            ActionUnit::AU9 => "Levator labii superioris alaeque nasi wrinkles the nose",
141            ActionUnit::AU10 => "Levator labii superioris raises the upper lip",
142            ActionUnit::AU11 => "Zygomaticus minor deepens the nasolabial fold",
143            ActionUnit::AU12 => "Zygomaticus major pulls the lip corners upward and outward",
144            ActionUnit::AU13 => "Levator anguli oris puffs the cheeks",
145            ActionUnit::AU14 => "Buccinator creates dimples at the lip corners",
146            ActionUnit::AU15 => "Depressor anguli oris pulls the lip corners downward",
147            ActionUnit::AU16 => "Depressor labii inferioris lowers the lower lip",
148            ActionUnit::AU17 => "Mentalis raises and wrinkles the chin",
149            ActionUnit::AU18 => "Incisivii labii pucker the lips",
150            ActionUnit::AU20 => "Risorius stretches the lip corners horizontally",
151            ActionUnit::AU22 => "Orbicularis oris creates a funnel/O-shape with the lips",
152            ActionUnit::AU23 => "Orbicularis oris narrows and tightens the lips",
153            ActionUnit::AU24 => "Orbicularis oris presses the lips together",
154            ActionUnit::AU25 => "Depressor labii or relaxed mentalis parts the lips",
155            ActionUnit::AU26 => "Internal pterygoid, digastric, etc. drop the jaw",
156            ActionUnit::AU27 => "Pterygoids, digastric open the mouth extremely wide",
157            ActionUnit::AU28 => "Orbicularis oris sucks the lips inward",
158            ActionUnit::AU41 => "Relaxation of levator palpebrae droops the upper lid",
159            ActionUnit::AU42 => "Orbicularis oculi narrows the eye opening",
160            ActionUnit::AU43 => "Relaxed levator palpebrae closes the eyes",
161            ActionUnit::AU44 => "Orbicularis oculi squints the eyes",
162            ActionUnit::AU45 => "Rapid closing and opening of the eyelids (blink)",
163            ActionUnit::AU46 => "Closing one eye (wink)",
164        }
165    }
166
167    /// All 30 action unit variants in canonical order.
168    pub fn all() -> &'static [ActionUnit] {
169        use ActionUnit::*;
170        &[
171            AU1, AU2, AU4, AU5, AU6, AU7, AU9, AU10, AU11, AU12, AU13, AU14, AU15, AU16, AU17,
172            AU18, AU20, AU22, AU23, AU24, AU25, AU26, AU27, AU28, AU41, AU42, AU43, AU44, AU45,
173            AU46,
174        ]
175    }
176
177    /// Upper face action units (brow, nose region).
178    pub fn upper_face() -> &'static [ActionUnit] {
179        use ActionUnit::*;
180        &[AU1, AU2, AU4, AU5, AU6, AU7, AU9, AU10]
181    }
182
183    /// Lower face action units (mouth region).
184    pub fn lower_face() -> &'static [ActionUnit] {
185        use ActionUnit::*;
186        &[
187            AU11, AU12, AU13, AU14, AU15, AU16, AU17, AU18, AU20, AU22, AU23, AU24, AU25, AU26,
188            AU27, AU28,
189        ]
190    }
191
192    /// Eye-related action units.
193    pub fn eye_units() -> &'static [ActionUnit] {
194        use ActionUnit::*;
195        &[AU41, AU42, AU43, AU44, AU45, AU46]
196    }
197
198    /// Construct an ActionUnit from its canonical number, if it exists.
199    pub fn from_number(n: u32) -> Option<ActionUnit> {
200        match n {
201            1 => Some(ActionUnit::AU1),
202            2 => Some(ActionUnit::AU2),
203            4 => Some(ActionUnit::AU4),
204            5 => Some(ActionUnit::AU5),
205            6 => Some(ActionUnit::AU6),
206            7 => Some(ActionUnit::AU7),
207            9 => Some(ActionUnit::AU9),
208            10 => Some(ActionUnit::AU10),
209            11 => Some(ActionUnit::AU11),
210            12 => Some(ActionUnit::AU12),
211            13 => Some(ActionUnit::AU13),
212            14 => Some(ActionUnit::AU14),
213            15 => Some(ActionUnit::AU15),
214            16 => Some(ActionUnit::AU16),
215            17 => Some(ActionUnit::AU17),
216            18 => Some(ActionUnit::AU18),
217            20 => Some(ActionUnit::AU20),
218            22 => Some(ActionUnit::AU22),
219            23 => Some(ActionUnit::AU23),
220            24 => Some(ActionUnit::AU24),
221            25 => Some(ActionUnit::AU25),
222            26 => Some(ActionUnit::AU26),
223            27 => Some(ActionUnit::AU27),
224            28 => Some(ActionUnit::AU28),
225            41 => Some(ActionUnit::AU41),
226            42 => Some(ActionUnit::AU42),
227            43 => Some(ActionUnit::AU43),
228            44 => Some(ActionUnit::AU44),
229            45 => Some(ActionUnit::AU45),
230            46 => Some(ActionUnit::AU46),
231            _ => None,
232        }
233    }
234}
235
236// ---------------------------------------------------------------------------
237// FacsState
238// ---------------------------------------------------------------------------
239
240/// FACS activation state: a mapping from Action Unit to intensity in [0..1].
241pub type FacsState = HashMap<ActionUnit, f32>;
242
243// ---------------------------------------------------------------------------
244// FacsMapper
245// ---------------------------------------------------------------------------
246
247/// Maps FACS Action Units to morph-target weights.
248///
249/// Each AU can influence one or more named morph targets with a configurable
250/// weight at full (1.0) intensity.  The [`Self::evaluate`] method scales each
251/// morph contribution by the AU intensity and accumulates the results.
252pub struct FacsMapper {
253    /// AU → list of (morph_name, weight_at_full_intensity)
254    mappings: HashMap<ActionUnit, Vec<(String, f32)>>,
255}
256
257impl FacsMapper {
258    /// Create an empty mapper.
259    pub fn new() -> Self {
260        Self {
261            mappings: HashMap::new(),
262        }
263    }
264
265    /// Register a morph target for the given AU.
266    ///
267    /// `weight` is the morph-target weight applied when the AU has intensity
268    /// 1.0.  Multiple calls for the same AU accumulate additional mappings.
269    pub fn add_mapping(&mut self, au: ActionUnit, morph: impl Into<String>, weight: f32) {
270        self.mappings
271            .entry(au)
272            .or_default()
273            .push((morph.into(), weight));
274    }
275
276    /// Return the list of `(morph_name, weight)` pairs registered for `au`.
277    pub fn mappings_for(&self, au: &ActionUnit) -> &[(String, f32)] {
278        self.mappings.get(au).map(|v| v.as_slice()).unwrap_or(&[])
279    }
280
281    /// Convert a [`FacsState`] into a flat map of morph-target weights.
282    ///
283    /// For each active AU, each associated morph target receives a contribution
284    /// of `intensity × weight_at_full`.  When multiple AUs influence the same
285    /// morph target, contributions are summed and clamped to `[0.0, 1.0]`.
286    pub fn evaluate(&self, state: &FacsState) -> HashMap<String, f32> {
287        let mut result: HashMap<String, f32> = HashMap::new();
288        for (au, &intensity) in state {
289            if let Some(pairs) = self.mappings.get(au) {
290                for (morph, max_weight) in pairs {
291                    let contribution = intensity * *max_weight;
292                    let entry = result.entry(morph.clone()).or_insert(0.0);
293                    *entry = (*entry + contribution).min(1.0);
294                }
295            }
296        }
297        result
298    }
299}
300
301impl Default for FacsMapper {
302    fn default() -> Self {
303        Self::new()
304    }
305}
306
307// ---------------------------------------------------------------------------
308// default_facs_mapper
309// ---------------------------------------------------------------------------
310
311/// Build a default [`FacsMapper`] with MakeHuman-style morph names.
312pub fn default_facs_mapper() -> FacsMapper {
313    let mut m = FacsMapper::new();
314
315    // Upper face / brow
316    m.add_mapping(ActionUnit::AU1, "brow_inner_raise", 0.8);
317    m.add_mapping(ActionUnit::AU2, "brow_outer_raise", 0.8);
318    m.add_mapping(ActionUnit::AU4, "brow_lower", 0.9);
319    m.add_mapping(ActionUnit::AU4, "brow_furrow", 0.6);
320    m.add_mapping(ActionUnit::AU5, "upper_lid_raise", 0.8);
321    m.add_mapping(ActionUnit::AU6, "cheek_raise", 0.7);
322    m.add_mapping(ActionUnit::AU7, "lid_tighten", 0.7);
323
324    // Nose
325    m.add_mapping(ActionUnit::AU9, "nose_wrinkle", 0.8);
326    m.add_mapping(ActionUnit::AU10, "upper_lip_raise", 0.7);
327
328    // Mouth
329    m.add_mapping(ActionUnit::AU11, "nasolabial_deepen", 0.7);
330    m.add_mapping(ActionUnit::AU12, "smile_mouth", 0.9);
331    m.add_mapping(ActionUnit::AU12, "lip_corner_pull", 0.7);
332    m.add_mapping(ActionUnit::AU13, "cheek_puff", 0.7);
333    m.add_mapping(ActionUnit::AU14, "dimple", 0.7);
334    m.add_mapping(ActionUnit::AU15, "lip_corner_depress", 0.8);
335    m.add_mapping(ActionUnit::AU16, "lower_lip_depress", 0.8);
336    m.add_mapping(ActionUnit::AU17, "chin_raise", 0.7);
337    m.add_mapping(ActionUnit::AU18, "lip_pucker", 0.8);
338    m.add_mapping(ActionUnit::AU20, "lip_stretch", 0.8);
339    m.add_mapping(ActionUnit::AU22, "lip_funnel", 0.8);
340    m.add_mapping(ActionUnit::AU23, "lip_tighten", 0.7);
341    m.add_mapping(ActionUnit::AU24, "lip_press", 0.7);
342    m.add_mapping(ActionUnit::AU25, "lips_part", 0.8);
343    m.add_mapping(ActionUnit::AU25, "jaw_open", 0.3);
344    m.add_mapping(ActionUnit::AU26, "jaw_drop", 0.9);
345    m.add_mapping(ActionUnit::AU27, "mouth_stretch", 0.9);
346    m.add_mapping(ActionUnit::AU27, "jaw_drop", 0.5);
347    m.add_mapping(ActionUnit::AU28, "lip_suck", 0.8);
348
349    // Eye
350    m.add_mapping(ActionUnit::AU41, "lid_droop", 0.8);
351    m.add_mapping(ActionUnit::AU42, "eye_slit", 0.7);
352    m.add_mapping(ActionUnit::AU43, "eyes_closed", 1.0);
353    m.add_mapping(ActionUnit::AU44, "eye_squint", 0.8);
354    m.add_mapping(ActionUnit::AU45, "blink_l", 1.0);
355    m.add_mapping(ActionUnit::AU45, "blink_r", 1.0);
356    m.add_mapping(ActionUnit::AU46, "wink_l", 1.0);
357
358    m
359}
360
361// ---------------------------------------------------------------------------
362// emotion_to_facs
363// ---------------------------------------------------------------------------
364
365/// Convert an emotion name to a prototypical FACS state.
366///
367/// Supported emotions: `"happy"`, `"sad"`, `"angry"`, `"surprised"`,
368/// `"fear"`, `"disgust"`.  Unknown names return an empty state.
369pub fn emotion_to_facs(emotion: &str) -> FacsState {
370    let mut state: FacsState = HashMap::new();
371
372    match emotion.to_lowercase().as_str() {
373        "happy" => {
374            state.insert(ActionUnit::AU6, 0.8);
375            state.insert(ActionUnit::AU12, 0.9);
376        }
377        "sad" => {
378            state.insert(ActionUnit::AU1, 0.8);
379            state.insert(ActionUnit::AU4, 0.6);
380            state.insert(ActionUnit::AU15, 0.7);
381        }
382        "angry" => {
383            state.insert(ActionUnit::AU4, 0.9);
384            state.insert(ActionUnit::AU5, 0.6);
385            state.insert(ActionUnit::AU7, 0.8);
386            state.insert(ActionUnit::AU23, 0.7);
387        }
388        "surprised" => {
389            state.insert(ActionUnit::AU1, 0.8);
390            state.insert(ActionUnit::AU2, 0.8);
391            state.insert(ActionUnit::AU5, 0.9);
392            state.insert(ActionUnit::AU26, 0.7);
393        }
394        "fear" => {
395            state.insert(ActionUnit::AU1, 0.8);
396            state.insert(ActionUnit::AU2, 0.8);
397            state.insert(ActionUnit::AU4, 0.6);
398            state.insert(ActionUnit::AU5, 0.8);
399            state.insert(ActionUnit::AU20, 0.7);
400        }
401        "disgust" => {
402            state.insert(ActionUnit::AU9, 0.9);
403            state.insert(ActionUnit::AU15, 0.7);
404            state.insert(ActionUnit::AU16, 0.6);
405        }
406        _ => {}
407    }
408
409    state
410}
411
412// ---------------------------------------------------------------------------
413// FacsIntensity
414// ---------------------------------------------------------------------------
415
416/// A FACS intensity value on a normalized `[0.0, 1.0]` scale.
417///
418/// In the FACS literature, intensity is often described with a five-letter
419/// scale: A (trace) through E (maximum).
420pub struct FacsIntensity(pub f32);
421
422impl FacsIntensity {
423    /// Parse a FACS letter-scale intensity.
424    ///
425    /// | Letter | Label   | Normalized |
426    /// |--------|---------|------------|
427    /// | A      | Trace   | 0.10       |
428    /// | B      | Slight  | 0.30       |
429    /// | C      | Marked  | 0.50       |
430    /// | D      | Extreme | 0.75       |
431    /// | E      | Maximum | 1.00       |
432    pub fn from_letter(letter: char) -> Option<Self> {
433        let v = match letter.to_ascii_uppercase() {
434            'A' => 0.10,
435            'B' => 0.30,
436            'C' => 0.50,
437            'D' => 0.75,
438            'E' => 1.00,
439            _ => return None,
440        };
441        Some(FacsIntensity(v))
442    }
443
444    /// Return the normalized `[0.0, 1.0]` intensity.
445    pub fn to_normalized(&self) -> f32 {
446        self.0.clamp(0.0, 1.0)
447    }
448
449    /// Construct from a normalized `[0.0, 1.0]` value.
450    pub fn from_normalized(v: f32) -> Self {
451        FacsIntensity(v.clamp(0.0, 1.0))
452    }
453}
454
455// ---------------------------------------------------------------------------
456// parse_facs_string
457// ---------------------------------------------------------------------------
458
459/// Parse a FACS string such as `"AU1+AU6+AU12"` or `"AU1A+AU12E"`.
460///
461/// Tokens are separated by `'+'`.  Each token must start with `"AU"` (case-
462/// insensitive) followed by a decimal number and an optional intensity letter
463/// (`A`–`E`).  Missing intensity defaults to 1.0.
464pub fn parse_facs_string(s: &str) -> FacsState {
465    let mut state: FacsState = HashMap::new();
466
467    for token in s.split('+') {
468        let token = token.trim();
469        if token.is_empty() {
470            continue;
471        }
472
473        // Strip leading "AU" or "au" prefix (case-insensitive).
474        let without_prefix = if token.to_uppercase().starts_with("AU") {
475            &token[2..]
476        } else {
477            continue;
478        };
479
480        // Split numeric part from optional trailing letter.
481        let last = without_prefix.chars().last();
482        let (num_str, intensity) = match last {
483            Some(c) if c.is_ascii_alphabetic() => {
484                let num_part = &without_prefix[..without_prefix.len() - c.len_utf8()];
485                let intensity = FacsIntensity::from_letter(c)
486                    .map(|fi| fi.to_normalized())
487                    .unwrap_or(1.0);
488                (num_part, intensity)
489            }
490            _ => (without_prefix, 1.0_f32),
491        };
492
493        if let Ok(n) = num_str.parse::<u32>() {
494            if let Some(au) = ActionUnit::from_number(n) {
495                state.insert(au, intensity);
496            }
497        }
498    }
499
500    state
501}
502
503// ---------------------------------------------------------------------------
504// Tests
505// ---------------------------------------------------------------------------
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn test_action_unit_number() {
513        assert_eq!(ActionUnit::AU1.number(), 1);
514        assert_eq!(ActionUnit::AU12.number(), 12);
515        assert_eq!(ActionUnit::AU45.number(), 45);
516        assert_eq!(ActionUnit::AU46.number(), 46);
517    }
518
519    #[test]
520    fn test_action_unit_name() {
521        assert_eq!(ActionUnit::AU1.name(), "Inner Brow Raise");
522        assert_eq!(ActionUnit::AU12.name(), "Lip Corner Puller");
523        assert_eq!(ActionUnit::AU45.name(), "Blink");
524        assert_eq!(ActionUnit::AU26.name(), "Jaw Drop");
525    }
526
527    #[test]
528    fn test_action_unit_all() {
529        let all = ActionUnit::all();
530        assert_eq!(all.len(), 30);
531        // First and last
532        assert_eq!(all[0], ActionUnit::AU1);
533        assert_eq!(all[all.len() - 1], ActionUnit::AU46);
534        // Every AU number is unique
535        let numbers: Vec<u32> = all.iter().map(|au| au.number()).collect();
536        let mut sorted = numbers.clone();
537        sorted.sort_unstable();
538        sorted.dedup();
539        assert_eq!(sorted.len(), numbers.len());
540    }
541
542    #[test]
543    fn test_upper_face_units() {
544        let upper = ActionUnit::upper_face();
545        assert_eq!(upper.len(), 8);
546        assert!(upper.contains(&ActionUnit::AU1));
547        assert!(upper.contains(&ActionUnit::AU6));
548        assert!(upper.contains(&ActionUnit::AU10));
549        // No mouth or eye AUs
550        assert!(!upper.contains(&ActionUnit::AU12));
551        assert!(!upper.contains(&ActionUnit::AU43));
552    }
553
554    #[test]
555    fn test_lower_face_units() {
556        let lower = ActionUnit::lower_face();
557        assert_eq!(lower.len(), 16);
558        assert!(lower.contains(&ActionUnit::AU12));
559        assert!(lower.contains(&ActionUnit::AU26));
560        assert!(lower.contains(&ActionUnit::AU28));
561        // No upper-face or eye AUs
562        assert!(!lower.contains(&ActionUnit::AU1));
563        assert!(!lower.contains(&ActionUnit::AU45));
564    }
565
566    #[test]
567    fn test_eye_units() {
568        let eyes = ActionUnit::eye_units();
569        assert_eq!(eyes.len(), 6);
570        assert!(eyes.contains(&ActionUnit::AU41));
571        assert!(eyes.contains(&ActionUnit::AU43));
572        assert!(eyes.contains(&ActionUnit::AU46));
573        // No brow or mouth AUs
574        assert!(!eyes.contains(&ActionUnit::AU1));
575        assert!(!eyes.contains(&ActionUnit::AU12));
576    }
577
578    #[test]
579    fn test_facs_mapper_add_and_evaluate() {
580        let mut mapper = FacsMapper::new();
581        mapper.add_mapping(ActionUnit::AU12, "smile", 0.9);
582        mapper.add_mapping(ActionUnit::AU6, "cheek", 0.7);
583
584        let mut state: FacsState = HashMap::new();
585        state.insert(ActionUnit::AU12, 1.0);
586        state.insert(ActionUnit::AU6, 0.5);
587
588        let weights = mapper.evaluate(&state);
589        let smile = *weights.get("smile").expect("smile morph missing");
590        let cheek = *weights.get("cheek").expect("cheek morph missing");
591
592        assert!((smile - 0.9).abs() < 1e-5, "smile={smile}");
593        assert!((cheek - 0.35).abs() < 1e-5, "cheek={cheek}");
594    }
595
596    #[test]
597    fn test_default_facs_mapper() {
598        let mapper = default_facs_mapper();
599
600        // AU12 should map to smile_mouth and lip_corner_pull
601        let m12 = mapper.mappings_for(&ActionUnit::AU12);
602        assert!(!m12.is_empty());
603        let has_smile = m12.iter().any(|(n, _)| n == "smile_mouth");
604        assert!(has_smile, "AU12 should map to smile_mouth");
605
606        // AU45 should map to blink_l and blink_r
607        let m45 = mapper.mappings_for(&ActionUnit::AU45);
608        let has_blink_l = m45.iter().any(|(n, _)| n == "blink_l");
609        let has_blink_r = m45.iter().any(|(n, _)| n == "blink_r");
610        assert!(has_blink_l, "AU45 missing blink_l");
611        assert!(has_blink_r, "AU45 missing blink_r");
612
613        // AU26 should map to jaw_drop at 0.9
614        let m26 = mapper.mappings_for(&ActionUnit::AU26);
615        let jaw_w = m26.iter().find(|(n, _)| n == "jaw_drop").map(|(_, w)| *w);
616        assert_eq!(jaw_w, Some(0.9));
617    }
618
619    #[test]
620    fn test_emotion_to_facs_happy() {
621        let state = emotion_to_facs("happy");
622        assert_eq!(state.get(&ActionUnit::AU6), Some(&0.8));
623        assert_eq!(state.get(&ActionUnit::AU12), Some(&0.9));
624        // Should not contain brow-lowering AU
625        assert!(!state.contains_key(&ActionUnit::AU4));
626    }
627
628    #[test]
629    fn test_emotion_to_facs_angry() {
630        let state = emotion_to_facs("angry");
631        assert_eq!(state.get(&ActionUnit::AU4), Some(&0.9));
632        assert_eq!(state.get(&ActionUnit::AU5), Some(&0.6));
633        assert_eq!(state.get(&ActionUnit::AU7), Some(&0.8));
634        assert_eq!(state.get(&ActionUnit::AU23), Some(&0.7));
635    }
636
637    #[test]
638    fn test_facs_intensity_from_letter() {
639        assert_eq!(
640            FacsIntensity::from_letter('A')
641                .expect("should succeed")
642                .to_normalized(),
643            0.10
644        );
645        assert_eq!(
646            FacsIntensity::from_letter('B')
647                .expect("should succeed")
648                .to_normalized(),
649            0.30
650        );
651        assert_eq!(
652            FacsIntensity::from_letter('C')
653                .expect("should succeed")
654                .to_normalized(),
655            0.50
656        );
657        assert_eq!(
658            FacsIntensity::from_letter('D')
659                .expect("should succeed")
660                .to_normalized(),
661            0.75
662        );
663        assert_eq!(
664            FacsIntensity::from_letter('E')
665                .expect("should succeed")
666                .to_normalized(),
667            1.00
668        );
669        // Lowercase should work too
670        assert_eq!(
671            FacsIntensity::from_letter('e')
672                .expect("should succeed")
673                .to_normalized(),
674            1.00
675        );
676        // Unknown letter
677        assert!(FacsIntensity::from_letter('Z').is_none());
678    }
679
680    #[test]
681    fn test_facs_intensity_normalized() {
682        let fi = FacsIntensity::from_normalized(0.6);
683        assert!((fi.to_normalized() - 0.6).abs() < 1e-6);
684
685        // Clamping
686        let over = FacsIntensity::from_normalized(1.5);
687        assert_eq!(over.to_normalized(), 1.0);
688        let under = FacsIntensity::from_normalized(-0.5);
689        assert_eq!(under.to_normalized(), 0.0);
690    }
691
692    #[test]
693    fn test_parse_facs_string_simple() {
694        let state = parse_facs_string("AU12");
695        assert_eq!(state.get(&ActionUnit::AU12), Some(&1.0));
696        assert_eq!(state.len(), 1);
697    }
698
699    #[test]
700    fn test_parse_facs_string_multi() {
701        let state = parse_facs_string("AU1+AU6+AU12E");
702        assert_eq!(state.get(&ActionUnit::AU1), Some(&1.0));
703        assert_eq!(state.get(&ActionUnit::AU6), Some(&1.0));
704        assert_eq!(state.get(&ActionUnit::AU12), Some(&1.0)); // 'E' → 1.0
705        assert_eq!(state.len(), 3);
706
707        // With intensity letters
708        let state2 = parse_facs_string("AU4A+AU12C");
709        assert_eq!(state2.get(&ActionUnit::AU4), Some(&0.10));
710        assert_eq!(state2.get(&ActionUnit::AU12), Some(&0.50));
711    }
712}