Skip to main content

oxihuman_morph/
micro_expression.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Micro-expression injection layer: transient involuntary expression flashes.
5
6use std::collections::HashMap;
7
8// ── Types ────────────────────────────────────────────────────────────────────
9
10/// A single micro-expression definition.
11#[allow(dead_code)]
12pub struct MicroExpression {
13    /// Human-readable name, e.g. "disgust_flash".
14    pub name: String,
15    /// Morph target weights for this expression.
16    pub morph_weights: HashMap<String, f32>,
17    /// Typical duration in seconds (0.04..0.2).
18    pub duration: f32,
19    /// Intensity multiplier 0..1.
20    pub intensity: f32,
21}
22
23/// A scheduled micro-expression event in a timeline.
24#[allow(dead_code)]
25pub struct MicroExpressionEvent {
26    /// The micro-expression to play.
27    pub expr: MicroExpression,
28    /// When (seconds) to start the event.
29    pub trigger_time: f32,
30    /// Fade-in duration in seconds.
31    pub fade_in: f32,
32    /// Fade-out duration in seconds.
33    pub fade_out: f32,
34}
35
36/// A layer that applies micro-expressions on top of a base weight state.
37#[allow(dead_code)]
38pub struct MicroExpressionLayer {
39    /// Scheduled events.
40    pub events: Vec<MicroExpressionEvent>,
41    /// Base morph weights (always present).
42    pub base_weights: HashMap<String, f32>,
43}
44
45// ── Implementations ──────────────────────────────────────────────────────────
46
47impl MicroExpressionLayer {
48    /// Create a new layer with the given base weights and no events.
49    #[allow(dead_code)]
50    pub fn new(base_weights: HashMap<String, f32>) -> Self {
51        Self {
52            events: Vec::new(),
53            base_weights,
54        }
55    }
56
57    /// Schedule a new micro-expression event.
58    #[allow(dead_code)]
59    pub fn add_event(&mut self, event: MicroExpressionEvent) {
60        self.events.push(event);
61    }
62
63    /// Sample the combined morph weights at time `t`.
64    #[allow(dead_code)]
65    pub fn sample(&self, t: f32) -> HashMap<String, f32> {
66        let mut result = self.base_weights.clone();
67
68        for event in &self.events {
69            let blend = micro_expr_weight_at(event, t);
70            if blend > 0.0 {
71                result = merge_weights(&result, &event.expr.morph_weights, blend);
72            }
73        }
74        result
75    }
76}
77
78// ── Free functions ────────────────────────────────────────────────────────────
79
80/// Compute the trapezoid envelope weight for a micro-expression event at time `t`.
81/// Returns a value in 0..=`intensity`.
82#[allow(dead_code)]
83pub fn micro_expr_weight_at(event: &MicroExpressionEvent, t: f32) -> f32 {
84    let start = event.trigger_time;
85    let end = start + event.expr.duration;
86
87    // Before or after the event window (including fades).
88    if t < start - event.fade_in || t > end + event.fade_out {
89        return 0.0;
90    }
91
92    let env = if t < start {
93        // Fade-in phase.
94        let elapsed = t - (start - event.fade_in);
95        (elapsed / event.fade_in).clamp(0.0, 1.0)
96    } else if t <= end {
97        // Plateau phase.
98        1.0
99    } else {
100        // Fade-out phase.
101        let elapsed = t - end;
102        1.0 - (elapsed / event.fade_out).clamp(0.0, 1.0)
103    };
104
105    env * event.expr.intensity
106}
107
108/// Additively blend `overlay` onto `base` with a `blend` factor.
109/// Result is clamped to 1.0 per key.
110#[allow(dead_code)]
111pub fn merge_weights(
112    base: &HashMap<String, f32>,
113    overlay: &HashMap<String, f32>,
114    blend: f32,
115) -> HashMap<String, f32> {
116    let mut result = base.clone();
117    for (k, &v) in overlay {
118        let existing = result.get(k).copied().unwrap_or(0.0);
119        result.insert(k.clone(), (existing + v * blend).clamp(0.0, 1.0));
120    }
121    result
122}
123
124/// Return a standard library of named micro expressions.
125#[allow(dead_code)]
126pub fn standard_micro_expressions() -> Vec<MicroExpression> {
127    vec![
128        MicroExpression {
129            name: "disgust_flash".to_string(),
130            morph_weights: [
131                ("nose_wrinkle".to_string(), 0.8),
132                ("upper_lip_raise".to_string(), 0.7),
133                ("brow_lower_inner".to_string(), 0.5),
134            ]
135            .into_iter()
136            .collect(),
137            duration: 0.12,
138            intensity: 0.85,
139        },
140        MicroExpression {
141            name: "fear_flash".to_string(),
142            morph_weights: [
143                ("brow_raise_inner".to_string(), 0.9),
144                ("eye_widen".to_string(), 0.8),
145                ("lip_stretch".to_string(), 0.6),
146            ]
147            .into_iter()
148            .collect(),
149            duration: 0.10,
150            intensity: 0.80,
151        },
152        MicroExpression {
153            name: "surprise_flash".to_string(),
154            morph_weights: [
155                ("brow_raise_outer".to_string(), 0.95),
156                ("eye_widen".to_string(), 0.9),
157                ("jaw_drop".to_string(), 0.7),
158            ]
159            .into_iter()
160            .collect(),
161            duration: 0.08,
162            intensity: 0.90,
163        },
164        MicroExpression {
165            name: "contempt_flash".to_string(),
166            morph_weights: [
167                ("lip_corner_pull_right".to_string(), 0.7),
168                ("brow_lower_right".to_string(), 0.4),
169            ]
170            .into_iter()
171            .collect(),
172            duration: 0.15,
173            intensity: 0.75,
174        },
175        MicroExpression {
176            name: "joy_flash".to_string(),
177            morph_weights: [
178                ("cheek_raise".to_string(), 0.8),
179                ("lip_corner_pull".to_string(), 0.9),
180                ("eye_squint".to_string(), 0.6),
181            ]
182            .into_iter()
183            .collect(),
184            duration: 0.18,
185            intensity: 0.70,
186        },
187    ]
188}
189
190/// Inject random micro-expressions into `layer` over `duration` seconds at `rate`
191/// events/second using a simple LCG for deterministic randomness from `seed`.
192#[allow(dead_code)]
193pub fn inject_random_micros(layer: &mut MicroExpressionLayer, duration: f32, rate: f32, seed: u64) {
194    let library = standard_micro_expressions();
195    if library.is_empty() || rate <= 0.0 || duration <= 0.0 {
196        return;
197    }
198
199    // LCG constants (Knuth).
200    let mut state = seed;
201    let lcg_next = |s: &mut u64| -> u64 {
202        *s = s
203            .wrapping_mul(6364136223846793005)
204            .wrapping_add(1442695040888963407);
205        *s
206    };
207
208    // Expected number of events ≈ duration * rate; simulate Poisson-ish with fixed spacing + jitter.
209    let expected = (duration * rate).ceil() as usize;
210    let avg_interval = duration / (expected as f32).max(1.0);
211
212    let mut t = 0.0_f32;
213    for _ in 0..expected {
214        // Jitter the interval by ±50% of avg_interval.
215        let rand_u = lcg_next(&mut state);
216        let jitter = (rand_u % 1000) as f32 / 1000.0 - 0.5; // [-0.5, 0.5)
217        t += avg_interval * (1.0 + jitter);
218        if t >= duration {
219            break;
220        }
221
222        // Pick a random expression from the library.
223        let idx_u = lcg_next(&mut state);
224        let idx = (idx_u % library.len() as u64) as usize;
225        let src = &library[idx];
226
227        let event = MicroExpressionEvent {
228            expr: MicroExpression {
229                name: src.name.clone(),
230                morph_weights: src.morph_weights.clone(),
231                duration: src.duration,
232                intensity: src.intensity,
233            },
234            trigger_time: t,
235            fade_in: 0.02,
236            fade_out: 0.04,
237        };
238        layer.add_event(event);
239    }
240}
241
242// ── Tests ─────────────────────────────────────────────────────────────────────
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    fn simple_event(trigger: f32, duration: f32, intensity: f32) -> MicroExpressionEvent {
248        MicroExpressionEvent {
249            expr: MicroExpression {
250                name: "test".to_string(),
251                morph_weights: [("brow".to_string(), 1.0)].into_iter().collect(),
252                duration,
253                intensity,
254            },
255            trigger_time: trigger,
256            fade_in: 0.1,
257            fade_out: 0.1,
258        }
259    }
260
261    // 1. micro_expr_weight_at before event → 0
262    #[test]
263    fn test_weight_before_event() {
264        let ev = simple_event(1.0, 0.1, 0.8);
265        assert!((micro_expr_weight_at(&ev, 0.5) - 0.0).abs() < 1e-6);
266    }
267
268    // 2. micro_expr_weight_at at peak → intensity
269    #[test]
270    fn test_weight_at_peak() {
271        let ev = simple_event(1.0, 0.1, 0.8);
272        // Plateau is 1.0 .. 1.1; t=1.05 is peak.
273        let w = micro_expr_weight_at(&ev, 1.05);
274        assert!((w - 0.8).abs() < 1e-5);
275    }
276
277    // 3. micro_expr_weight_at during fade_in → partial
278    #[test]
279    fn test_weight_during_fade_in() {
280        let ev = simple_event(1.0, 0.1, 1.0);
281        // fade_in=0.1, start=1.0. At t=0.95, elapsed=0.05 → env=0.5 → weight=0.5
282        let w = micro_expr_weight_at(&ev, 0.95);
283        assert!((w - 0.5).abs() < 1e-5);
284    }
285
286    // 4. micro_expr_weight_at during fade_out → partial
287    #[test]
288    fn test_weight_during_fade_out() {
289        let ev = simple_event(1.0, 0.1, 1.0);
290        // end=1.1, fade_out=0.1. At t=1.15, elapsed=0.05 → env=0.5
291        let w = micro_expr_weight_at(&ev, 1.15);
292        assert!((w - 0.5).abs() < 1e-5);
293    }
294
295    // 5. micro_expr_weight_at after event → 0
296    #[test]
297    fn test_weight_after_event() {
298        let ev = simple_event(1.0, 0.1, 0.8);
299        // end=1.1, fade_out=0.1, so fully gone at t > 1.2
300        let w = micro_expr_weight_at(&ev, 1.5);
301        assert!((w - 0.0).abs() < 1e-6);
302    }
303
304    // 6. sample at peak returns merged weights
305    #[test]
306    fn test_sample_at_peak_merges() {
307        let base: HashMap<String, f32> = [("brow".to_string(), 0.0)].into_iter().collect();
308        let mut layer = MicroExpressionLayer::new(base);
309        layer.add_event(simple_event(0.0, 0.5, 1.0));
310        // At t=0.25 (plateau), brow should be 1.0 (base 0.0 + 1.0*1.0).
311        let result = layer.sample(0.25);
312        assert!((result["brow"] - 1.0).abs() < 1e-5);
313    }
314
315    // 7. merge_weights: additive blend
316    #[test]
317    fn test_merge_weights_additive() {
318        let base: HashMap<String, f32> = [("cheek".to_string(), 0.3)].into_iter().collect();
319        let overlay: HashMap<String, f32> = [("cheek".to_string(), 0.4)].into_iter().collect();
320        let result = merge_weights(&base, &overlay, 1.0);
321        assert!((result["cheek"] - 0.7).abs() < 1e-5);
322    }
323
324    // 8. merge_weights: clamp to 1.0
325    #[test]
326    fn test_merge_weights_clamp() {
327        let base: HashMap<String, f32> = [("x".to_string(), 0.8)].into_iter().collect();
328        let overlay: HashMap<String, f32> = [("x".to_string(), 0.9)].into_iter().collect();
329        let result = merge_weights(&base, &overlay, 1.0);
330        assert!((result["x"] - 1.0).abs() < 1e-6, "should clamp to 1.0");
331    }
332
333    // 9. standard_micro_expressions count >= 5
334    #[test]
335    fn test_standard_micro_expressions_count() {
336        let lib = standard_micro_expressions();
337        assert!(lib.len() >= 5);
338    }
339
340    // 10. inject_random_micros adds events to the layer
341    #[test]
342    fn test_inject_random_micros_adds_events() {
343        let base: HashMap<String, f32> = HashMap::new();
344        let mut layer = MicroExpressionLayer::new(base);
345        inject_random_micros(&mut layer, 10.0, 2.0, 42);
346        assert!(
347            !layer.events.is_empty(),
348            "should have injected at least one event"
349        );
350    }
351
352    // 11. layer with no events returns base weights unchanged
353    #[test]
354    fn test_layer_no_events_returns_base() {
355        let base: HashMap<String, f32> = [("nose".to_string(), 0.4), ("jaw".to_string(), 0.6)]
356            .into_iter()
357            .collect();
358        let layer = MicroExpressionLayer::new(base.clone());
359        let result = layer.sample(5.0);
360        for (k, &v) in &base {
361            assert!((result[k] - v).abs() < 1e-6);
362        }
363    }
364
365    // 12. merge_weights: new key in overlay is added to result
366    #[test]
367    fn test_merge_weights_new_key_added() {
368        let base: HashMap<String, f32> = [("a".to_string(), 0.5)].into_iter().collect();
369        let overlay: HashMap<String, f32> = [("b".to_string(), 0.6)].into_iter().collect();
370        let result = merge_weights(&base, &overlay, 0.5);
371        assert!((result["a"] - 0.5).abs() < 1e-6);
372        assert!((result["b"] - 0.3).abs() < 1e-5);
373    }
374}