Skip to main content

oxihuman_morph/
expression_retarget.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4#![allow(dead_code)]
5
6use std::collections::HashMap;
7
8/// A map from morph-target name to blend weight.
9pub type MorphWeights = HashMap<String, f32>;
10
11// ---------------------------------------------------------------------------
12// RetargetMap
13// ---------------------------------------------------------------------------
14
15/// A bidirectional name mapping between two character rigs.
16///
17/// Optionally carries prefix-rewrite rules inserted by [`build_prefix_map`].
18pub struct RetargetMap {
19    /// source_name → target_name (explicit entries)
20    forward: HashMap<String, String>,
21    /// target_name → source_name (explicit entries)
22    inverse: HashMap<String, String>,
23    /// (src_prefix, tgt_prefix) rules for dynamic resolution
24    prefix_rules: Vec<(String, String)>,
25}
26
27impl RetargetMap {
28    /// Create an empty map with no prefix rules.
29    pub fn new() -> Self {
30        Self {
31            forward: HashMap::new(),
32            inverse: HashMap::new(),
33            prefix_rules: Vec::new(),
34        }
35    }
36
37    /// Internal constructor that stores prefix rules.
38    fn new_with_prefix_rules(rules: &[(&str, &str)]) -> Self {
39        Self {
40            forward: HashMap::new(),
41            inverse: HashMap::new(),
42            prefix_rules: rules
43                .iter()
44                .map(|&(s, t)| (s.to_owned(), t.to_owned()))
45                .collect(),
46        }
47    }
48
49    /// Register a source ↔ target name pair (explicit entry).
50    pub fn add(&mut self, source: impl Into<String>, target: impl Into<String>) {
51        let s = source.into();
52        let t = target.into();
53        self.forward.insert(s.clone(), t.clone());
54        self.inverse.insert(t, s);
55    }
56
57    /// Look up the target name for a given source name.
58    ///
59    /// Checks explicit entries first, then prefix rules.
60    pub fn forward(&self, source: &str) -> Option<&str> {
61        if let Some(t) = self.forward.get(source) {
62            return Some(t.as_str());
63        }
64        // Try prefix rules (returns a &str into a temporary; we need a
65        // heap-allocated version).  Because we cannot return a &str into a
66        // local String, prefix rules are resolved by `retarget_weights` and
67        // the public standalone `retarget_weights` function directly.
68        // For the purpose of this method we only return explicit entries.
69        // Prefix-rule callers should use `forward_owned`.
70        None
71    }
72
73    /// Like `forward` but returns an owned `String`; also resolves prefix rules.
74    pub fn forward_owned(&self, source: &str) -> Option<String> {
75        if let Some(t) = self.forward.get(source) {
76            return Some(t.clone());
77        }
78        for (src_pfx, tgt_pfx) in &self.prefix_rules {
79            if let Some(suffix) = source.strip_prefix(src_pfx.as_str()) {
80                return Some(format!("{}{}", tgt_pfx, suffix));
81            }
82        }
83        None
84    }
85
86    /// Look up the source name for a given target name.
87    pub fn inverse(&self, target: &str) -> Option<&str> {
88        self.inverse.get(target).map(|s| s.as_str())
89    }
90
91    /// Remap `source_weights` keys through the forward mapping; drop unmapped.
92    pub fn retarget_weights(&self, source_weights: &MorphWeights) -> MorphWeights {
93        let mut out = MorphWeights::new();
94        for (k, &v) in source_weights {
95            if let Some(mapped) = self.forward_owned(k) {
96                out.insert(mapped, v);
97            }
98        }
99        out
100    }
101
102    /// Remap `target_weights` keys through the inverse mapping; drop unmapped.
103    pub fn inverse_retarget_weights(&self, target_weights: &MorphWeights) -> MorphWeights {
104        let mut out = MorphWeights::new();
105        for (k, &v) in target_weights {
106            if let Some(mapped) = self.inverse(k) {
107                out.insert(mapped.to_owned(), v);
108            }
109        }
110        out
111    }
112
113    /// Number of explicit entries stored (prefix rules are not counted).
114    pub fn len(&self) -> usize {
115        self.forward.len()
116    }
117
118    /// Returns `true` when there are no explicit entries and no prefix rules.
119    pub fn is_empty(&self) -> bool {
120        self.forward.is_empty() && self.prefix_rules.is_empty()
121    }
122}
123
124impl Default for RetargetMap {
125    fn default() -> Self {
126        Self::new()
127    }
128}
129
130// ---------------------------------------------------------------------------
131// UnmappedPolicy / RetargetConfig
132// ---------------------------------------------------------------------------
133
134/// How to handle source keys that have no forward mapping.
135pub enum UnmappedPolicy {
136    /// Silently drop the key.
137    Drop,
138    /// Pass the key through unchanged.
139    PassThrough,
140    /// Prepend `RetargetConfig::prefix` to the key.
141    MapToPrefix,
142}
143
144/// Full configuration for [`retarget_weights`].
145pub struct RetargetConfig {
146    /// Multiplier applied to every output weight (default `1.0`).
147    pub weight_scale: f32,
148    /// Behaviour for keys absent from the [`RetargetMap`].
149    pub unmapped_policy: UnmappedPolicy,
150    /// Prefix used when `unmapped_policy` is [`UnmappedPolicy::MapToPrefix`].
151    pub prefix: String,
152    /// Clamp output weights to `[0.0, 1.0]`.
153    pub clamp_output: bool,
154}
155
156impl Default for RetargetConfig {
157    fn default() -> Self {
158        Self {
159            weight_scale: 1.0,
160            unmapped_policy: UnmappedPolicy::Drop,
161            prefix: String::new(),
162            clamp_output: false,
163        }
164    }
165}
166
167// ---------------------------------------------------------------------------
168// RetargetStats
169// ---------------------------------------------------------------------------
170
171/// Statistics produced by [`retarget_stats`].
172pub struct RetargetStats {
173    pub source_count: usize,
174    pub mapped_count: usize,
175    pub unmapped_count: usize,
176    pub mapping_rate: f32,
177}
178
179// ---------------------------------------------------------------------------
180// Standalone functions
181// ---------------------------------------------------------------------------
182
183/// Full retarget: apply map + config to `weights`.
184pub fn retarget_weights(
185    weights: &MorphWeights,
186    map: &RetargetMap,
187    config: &RetargetConfig,
188) -> MorphWeights {
189    let mut out = MorphWeights::new();
190    for (k, &v) in weights {
191        let mut val = v * config.weight_scale;
192        if config.clamp_output {
193            val = val.clamp(0.0, 1.0);
194        }
195        match map.forward_owned(k) {
196            Some(mapped) => {
197                out.insert(mapped, val);
198            }
199            None => match config.unmapped_policy {
200                UnmappedPolicy::Drop => {}
201                UnmappedPolicy::PassThrough => {
202                    out.insert(k.clone(), val);
203                }
204                UnmappedPolicy::MapToPrefix => {
205                    out.insert(format!("{}{}", config.prefix, k), val);
206                }
207            },
208        }
209    }
210    out
211}
212
213/// Linear interpolation between two weight maps.
214///
215/// Keys present in either map are included; missing values are treated as 0.
216pub fn blend_retargeted(source: &MorphWeights, target: &MorphWeights, t: f32) -> MorphWeights {
217    let mut all_keys: Vec<String> = source.keys().cloned().collect();
218    for k in target.keys() {
219        if !source.contains_key(k.as_str()) {
220            all_keys.push(k.clone());
221        }
222    }
223    let mut out = MorphWeights::new();
224    for k in all_keys {
225        let a = source.get(k.as_str()).copied().unwrap_or(0.0);
226        let b = target.get(k.as_str()).copied().unwrap_or(0.0);
227        out.insert(k, a + (b - a) * t);
228    }
229    out
230}
231
232/// Multiply every weight in `weights` by `scale`.
233pub fn scale_retarget_weights(weights: &MorphWeights, scale: f32) -> MorphWeights {
234    weights
235        .iter()
236        .map(|(k, &v)| (k.clone(), v * scale))
237        .collect()
238}
239
240/// Build a [`RetargetMap`] from prefix-pair rules.
241///
242/// For each `(src_prefix, tgt_prefix)` in `prefixes`, any source key that
243/// starts with `src_prefix` maps to a target key where the prefix is replaced
244/// with `tgt_prefix`.  The first matching rule wins.
245///
246/// # Example
247/// ```
248/// use oxihuman_morph::expression_retarget::build_prefix_map;
249/// let map = build_prefix_map(&[("jaw_", "mouth_"), ("brow_", "brows_")]);
250/// assert_eq!(map.forward_owned("jaw_open"), Some("mouth_open".to_owned()));
251/// assert_eq!(map.forward_owned("brow_raise"), Some("brows_raise".to_owned()));
252/// ```
253pub fn build_prefix_map(prefixes: &[(&str, &str)]) -> RetargetMap {
254    RetargetMap::new_with_prefix_rules(prefixes)
255}
256
257/// Compute mapping statistics for a source → retargeted weight pair.
258pub fn retarget_stats(
259    source: &MorphWeights,
260    _retargeted: &MorphWeights,
261    map: &RetargetMap,
262) -> RetargetStats {
263    let source_count = source.len();
264    let mapped_count = source
265        .keys()
266        .filter(|k| map.forward_owned(k).is_some())
267        .count();
268    let unmapped_count = source_count - mapped_count;
269    let mapping_rate = if source_count == 0 {
270        0.0
271    } else {
272        mapped_count as f32 / source_count as f32
273    };
274    RetargetStats {
275        source_count,
276        mapped_count,
277        unmapped_count,
278        mapping_rate,
279    }
280}
281
282// ---------------------------------------------------------------------------
283// Factory functions
284// ---------------------------------------------------------------------------
285
286/// Map 10 common MakeHuman morph names to their DAZ Studio equivalents.
287pub fn makehuman_to_daz_map() -> RetargetMap {
288    let mut m = RetargetMap::new();
289    m.add("jaw_open", "mouth_open");
290    m.add("brow_raise_l", "brows_up_l");
291    m.add("brow_raise_r", "brows_up_r");
292    m.add("brow_lower_l", "brows_down_l");
293    m.add("brow_lower_r", "brows_down_r");
294    m.add("smile_l", "mouth_smile_l");
295    m.add("smile_r", "mouth_smile_r");
296    m.add("eye_blink_l", "eyes_closed_l");
297    m.add("eye_blink_r", "eyes_closed_r");
298    m.add("cheek_puff", "cheeks_puff");
299    m
300}
301
302/// Map each key to itself (identity retarget).
303pub fn identity_map(keys: &[&str]) -> RetargetMap {
304    let mut m = RetargetMap::new();
305    for &k in keys {
306        m.add(k, k);
307    }
308    m
309}
310
311// ---------------------------------------------------------------------------
312// Tests
313// ---------------------------------------------------------------------------
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    fn w(pairs: &[(&str, f32)]) -> MorphWeights {
320        pairs.iter().map(|&(k, v)| (k.to_owned(), v)).collect()
321    }
322
323    // 1. RetargetMap::new creates empty map
324    #[test]
325    fn test_retarget_map_new_empty() {
326        let m = RetargetMap::new();
327        assert!(m.is_empty());
328        assert_eq!(m.len(), 0);
329    }
330
331    // 2. add / forward / inverse round-trip
332    #[test]
333    fn test_add_forward_inverse() {
334        let mut m = RetargetMap::new();
335        m.add("jaw_open", "mouth_open");
336        assert_eq!(m.forward("jaw_open"), Some("mouth_open"));
337        assert_eq!(m.inverse("mouth_open"), Some("jaw_open"));
338        assert_eq!(m.len(), 1);
339    }
340
341    // 3. forward returns None for unknown key
342    #[test]
343    fn test_forward_unknown() {
344        let m = RetargetMap::new();
345        assert_eq!(m.forward("nonexistent"), None);
346    }
347
348    // 4. inverse returns None for unknown key
349    #[test]
350    fn test_inverse_unknown() {
351        let m = RetargetMap::new();
352        assert_eq!(m.inverse("nonexistent"), None);
353    }
354
355    // 5. RetargetMap::retarget_weights maps keys correctly
356    #[test]
357    fn test_retarget_map_retarget_weights() {
358        let mut m = RetargetMap::new();
359        m.add("jaw_open", "mouth_open");
360        m.add("smile_l", "mouth_smile_l");
361        let src = w(&[("jaw_open", 0.8), ("smile_l", 0.5), ("unknown_key", 1.0)]);
362        let out = m.retarget_weights(&src);
363        assert!((out["mouth_open"] - 0.8).abs() < 1e-6);
364        assert!((out["mouth_smile_l"] - 0.5).abs() < 1e-6);
365        assert!(!out.contains_key("unknown_key"));
366        assert_eq!(out.len(), 2);
367    }
368
369    // 6. inverse_retarget_weights
370    #[test]
371    fn test_inverse_retarget_weights() {
372        let mut m = RetargetMap::new();
373        m.add("jaw_open", "mouth_open");
374        let tgt = w(&[("mouth_open", 0.7)]);
375        let out = m.inverse_retarget_weights(&tgt);
376        assert!((out["jaw_open"] - 0.7).abs() < 1e-6);
377    }
378
379    // 7. retarget_weights with UnmappedPolicy::Drop
380    #[test]
381    fn test_retarget_weights_drop() {
382        let mut m = RetargetMap::new();
383        m.add("jaw_open", "mouth_open");
384        let src = w(&[("jaw_open", 0.6), ("extra", 0.3)]);
385        let cfg = RetargetConfig {
386            unmapped_policy: UnmappedPolicy::Drop,
387            ..Default::default()
388        };
389        let out = retarget_weights(&src, &m, &cfg);
390        assert!(out.contains_key("mouth_open"));
391        assert!(!out.contains_key("extra"));
392    }
393
394    // 8. retarget_weights with UnmappedPolicy::PassThrough
395    #[test]
396    fn test_retarget_weights_passthrough() {
397        let mut m = RetargetMap::new();
398        m.add("jaw_open", "mouth_open");
399        let src = w(&[("jaw_open", 0.6), ("extra", 0.3)]);
400        let cfg = RetargetConfig {
401            unmapped_policy: UnmappedPolicy::PassThrough,
402            ..Default::default()
403        };
404        let out = retarget_weights(&src, &m, &cfg);
405        assert!(out.contains_key("mouth_open"));
406        assert!(out.contains_key("extra"));
407        assert!((out["extra"] - 0.3).abs() < 1e-6);
408    }
409
410    // 9. retarget_weights with UnmappedPolicy::MapToPrefix
411    #[test]
412    fn test_retarget_weights_map_to_prefix() {
413        let m = RetargetMap::new();
414        let src = w(&[("smile", 0.4)]);
415        let cfg = RetargetConfig {
416            unmapped_policy: UnmappedPolicy::MapToPrefix,
417            prefix: "pfx_".to_owned(),
418            ..Default::default()
419        };
420        let out = retarget_weights(&src, &m, &cfg);
421        assert!(out.contains_key("pfx_smile"));
422        assert!((out["pfx_smile"] - 0.4).abs() < 1e-6);
423    }
424
425    // 10. retarget_weights applies weight_scale
426    #[test]
427    fn test_retarget_weights_scale() {
428        let mut m = RetargetMap::new();
429        m.add("a", "b");
430        let src = w(&[("a", 0.5)]);
431        let cfg = RetargetConfig {
432            weight_scale: 2.0,
433            unmapped_policy: UnmappedPolicy::Drop,
434            ..Default::default()
435        };
436        let out = retarget_weights(&src, &m, &cfg);
437        assert!((out["b"] - 1.0).abs() < 1e-6);
438    }
439
440    // 11. retarget_weights clamps output when clamp_output=true
441    #[test]
442    fn test_retarget_weights_clamp() {
443        let mut m = RetargetMap::new();
444        m.add("a", "b");
445        let src = w(&[("a", 2.0)]);
446        let cfg = RetargetConfig {
447            weight_scale: 1.0,
448            clamp_output: true,
449            unmapped_policy: UnmappedPolicy::Drop,
450            ..Default::default()
451        };
452        let out = retarget_weights(&src, &m, &cfg);
453        assert!((out["b"] - 1.0).abs() < 1e-6);
454    }
455
456    // 12. blend_retargeted at t=0 returns source
457    #[test]
458    fn test_blend_retargeted_t0() {
459        let s = w(&[("a", 0.3), ("b", 0.7)]);
460        let t = w(&[("a", 1.0), ("b", 0.0)]);
461        let out = blend_retargeted(&s, &t, 0.0);
462        assert!((out["a"] - 0.3).abs() < 1e-6);
463        assert!((out["b"] - 0.7).abs() < 1e-6);
464    }
465
466    // 13. blend_retargeted at t=1 returns target
467    #[test]
468    fn test_blend_retargeted_t1() {
469        let s = w(&[("a", 0.3), ("b", 0.7)]);
470        let t = w(&[("a", 1.0), ("b", 0.0)]);
471        let out = blend_retargeted(&s, &t, 1.0);
472        assert!((out["a"] - 1.0).abs() < 1e-6);
473        assert!((out["b"] - 0.0).abs() < 1e-6);
474    }
475
476    // 14. blend_retargeted includes keys unique to either map
477    #[test]
478    fn test_blend_retargeted_union_keys() {
479        let s = w(&[("only_src", 0.5)]);
480        let t = w(&[("only_tgt", 0.8)]);
481        let out = blend_retargeted(&s, &t, 0.5);
482        assert!((out["only_src"] - 0.25).abs() < 1e-6);
483        assert!((out["only_tgt"] - 0.4).abs() < 1e-6);
484    }
485
486    // 15. scale_retarget_weights
487    #[test]
488    fn test_scale_retarget_weights() {
489        let src = w(&[("a", 0.4), ("b", 0.8)]);
490        let out = scale_retarget_weights(&src, 0.5);
491        assert!((out["a"] - 0.2).abs() < 1e-6);
492        assert!((out["b"] - 0.4).abs() < 1e-6);
493    }
494
495    // 16. build_prefix_map resolves keys via forward_owned
496    #[test]
497    fn test_build_prefix_map() {
498        let map = build_prefix_map(&[("jaw_", "mouth_"), ("brow_", "brows_")]);
499        assert_eq!(map.forward_owned("jaw_open"), Some("mouth_open".to_owned()));
500        assert_eq!(
501            map.forward_owned("brow_raise_l"),
502            Some("brows_raise_l".to_owned())
503        );
504        assert_eq!(map.forward_owned("unknown"), None);
505    }
506
507    // 17. build_prefix_map used with retarget_weights
508    #[test]
509    fn test_prefix_map_with_retarget_weights() {
510        let map = build_prefix_map(&[("mh_", "daz_")]);
511        let src = w(&[("mh_smile", 0.6), ("other", 0.2)]);
512        let cfg = RetargetConfig {
513            unmapped_policy: UnmappedPolicy::Drop,
514            ..Default::default()
515        };
516        let out = retarget_weights(&src, &map, &cfg);
517        assert!(out.contains_key("daz_smile"));
518        assert!(!out.contains_key("other"));
519    }
520
521    // 18. retarget_stats basic
522    #[test]
523    fn test_retarget_stats() {
524        let mut m = RetargetMap::new();
525        m.add("jaw_open", "mouth_open");
526        let src = w(&[("jaw_open", 0.8), ("unmapped", 0.2)]);
527        let retargeted = m.retarget_weights(&src);
528        let stats = retarget_stats(&src, &retargeted, &m);
529        assert_eq!(stats.source_count, 2);
530        assert_eq!(stats.mapped_count, 1);
531        assert_eq!(stats.unmapped_count, 1);
532        assert!((stats.mapping_rate - 0.5).abs() < 1e-6);
533    }
534
535    // 19. retarget_stats with empty source
536    #[test]
537    fn test_retarget_stats_empty() {
538        let m = RetargetMap::new();
539        let src = w(&[]);
540        let retargeted = w(&[]);
541        let stats = retarget_stats(&src, &retargeted, &m);
542        assert_eq!(stats.source_count, 0);
543        assert_eq!(stats.mapping_rate, 0.0);
544    }
545
546    // 20. makehuman_to_daz_map has 10 entries
547    #[test]
548    fn test_makehuman_to_daz_map_count() {
549        let m = makehuman_to_daz_map();
550        assert_eq!(m.len(), 10);
551    }
552
553    // 21. makehuman_to_daz_map spot-checks
554    #[test]
555    fn test_makehuman_to_daz_map_entries() {
556        let m = makehuman_to_daz_map();
557        assert_eq!(m.forward("jaw_open"), Some("mouth_open"));
558        assert_eq!(m.forward("brow_raise_l"), Some("brows_up_l"));
559        assert_eq!(m.forward("eye_blink_l"), Some("eyes_closed_l"));
560        assert_eq!(m.forward("cheek_puff"), Some("cheeks_puff"));
561    }
562
563    // 22. identity_map maps keys to themselves
564    #[test]
565    fn test_identity_map() {
566        let keys = ["smile", "blink", "jaw_open"];
567        let m = identity_map(&keys);
568        assert_eq!(m.len(), 3);
569        assert_eq!(m.forward("smile"), Some("smile"));
570        assert_eq!(m.inverse("blink"), Some("blink"));
571    }
572
573    // 23. identity_map retarget_weights preserves all keys
574    #[test]
575    fn test_identity_map_retarget_weights() {
576        let keys = ["a", "b"];
577        let m = identity_map(&keys);
578        let src = w(&[("a", 0.3), ("b", 0.7)]);
579        let out = m.retarget_weights(&src);
580        assert!((out["a"] - 0.3).abs() < 1e-6);
581        assert!((out["b"] - 0.7).abs() < 1e-6);
582    }
583}