"""
Rule-based blendshape -> dominant-expression classifier ("heuristic"
backend). Pure, dependency-free, zero I/O — this is the highest-value
function to get right in this plugin (a bug here silently mislabels every
detected face's expression), the same severity class as facemask's
expand_and_clip() and diarize's assign_speakers(). See
analysis/tests/test_expression_classifier.py.
Each of the 7 basic-emotion categories is backed by a small set of ARKit
blendshape names with weights, chosen from well-established FACS
Action-Unit -> blendshape correspondences (documented per-category below).
Neutral uses MediaPipe's own "_neutral" output category directly rather
than reinventing a neutral signal from the other 51 scores — it's already a
purpose-built "how neutral is this face" signal.
Known limitation, deliberately not "fixed" in this first cut: Surprised and
Fearful share most of their weighted blendshapes (brow raisers + eye
widening) and are only distinguished by jawOpen (Surprised) vs.
mouthStretchLeft/Right (Fearful) — real faces can activate both
simultaneously, so misclassification between these two specific categories
is expected and acceptable.
"""
from __future__ import annotations
#: The 7 basic-emotion categories this heuristic classifies into, in
#: argmax tie-break order (``"Neutral"`` listed first — see
#: :func:`classify_expression`'s Notes).
CATEGORIES: list[str] = [
"Neutral",
"Happy",
"Sad",
"Surprised",
"Angry",
"Disgusted",
"Fearful",
]
#: ``{category: {blendshape_name: weight}}``. Weighted MEAN (not sum) is
#: taken per category at classification time, so a category listing 6
#: blendshapes isn't unfairly favored over one listing 2 — every category's
#: score stays comparable in ``[0, 1]`` regardless of how many shapes it
#: references.
CATEGORY_WEIGHTS: dict[str, dict[str, float]] = {
"Neutral": {
"_neutral": 1.0,
},
"Happy": { # AU6 (cheek raiser) + AU12 (lip corner puller)
"mouthSmileLeft": 1.0,
"mouthSmileRight": 1.0,
"cheekSquintLeft": 0.3,
"cheekSquintRight": 0.3,
"mouthDimpleLeft": 0.2,
"mouthDimpleRight": 0.2,
},
"Sad": { # AU1 (inner brow raiser) + AU15 (lip corner depressor)
"mouthFrownLeft": 1.0,
"mouthFrownRight": 1.0,
"browInnerUp": 0.6,
"mouthLowerDownLeft": 0.2,
"mouthLowerDownRight": 0.2,
},
"Surprised": { # AU1+AU2 (brow raisers) + AU5 (lid raiser) + AU26 (jaw drop)
"jawOpen": 0.8,
"browInnerUp": 0.7,
"browOuterUpLeft": 0.7,
"browOuterUpRight": 0.7,
"eyeWideLeft": 0.5,
"eyeWideRight": 0.5,
},
"Angry": { # AU4 (brow lowerer) + AU7 (lid tightener) + AU23 (lip tight.)
"browDownLeft": 1.0,
"browDownRight": 1.0,
"eyeSquintLeft": 0.4,
"eyeSquintRight": 0.4,
"mouthPressLeft": 0.3,
"mouthPressRight": 0.3,
"noseSneerLeft": 0.2,
"noseSneerRight": 0.2,
},
"Disgusted": { # AU9 (nose wrinkler) + AU10 (upper lip raiser)
"noseSneerLeft": 1.0,
"noseSneerRight": 1.0,
"mouthUpperUpLeft": 0.6,
"mouthUpperUpRight": 0.6,
"browDownLeft": 0.2,
"browDownRight": 0.2,
},
"Fearful": { # AU1+AU2+AU5 (like Surprise) + AU20 (lip stretch, no jaw drop)
"browInnerUp": 0.6,
"browOuterUpLeft": 0.5,
"browOuterUpRight": 0.5,
"eyeWideLeft": 0.7,
"eyeWideRight": 0.7,
"mouthStretchLeft": 0.6,
"mouthStretchRight": 0.6,
},
}
# The winning category's weighted-mean score below this is treated as
# insufficient evidence -> forced to Neutral regardless of argmax.
_MIN_ACTIVATION = 0.15
[docs]
def classify_expression(
blendshape_names: list[str], blendshape_scores: list[float]
) -> tuple[str, float]:
"""Pick the dominant basic-emotion category from blendshape scores.
Parameters
----------
blendshape_names : list of str
MediaPipe blendshape category names, parallel to
``blendshape_scores`` (see :data:`~expression.detector.BLENDSHAPE_NAMES`).
blendshape_scores : list of float
Activation score in ``[0, 1]`` for each name. A name referenced
by :data:`CATEGORY_WEIGHTS` but missing here defaults to ``0.0``
(robust to a caller passing a truncated array).
Returns
-------
tuple of (str, float)
``(dominant_expression, dominant_score)``; ``dominant_expression``
is one of :data:`CATEGORIES`, ``dominant_score`` always in
``[0, 1]``.
Notes
-----
Each category's score is the weighted **mean** (not sum) of its
FACS-Action-Unit-derived blendshape weights, so a category listing
many blendshapes isn't unfairly favored over one listing few. Falls
back to ``"Neutral"`` when the winning score is below
:data:`_MIN_ACTIVATION`. Ties keep the first-seen category in
:data:`CATEGORIES` (``"Neutral"`` listed first) — a deliberate "when
in doubt, don't overclaim an emotion" default, consistent with
:func:`~diarize.pipeline.assign_speakers` leaving ``speaker=None`` on
a zero-overlap tie. See :doc:`/math/facial_expression` for the
formula.
"""
lookup = dict(zip(blendshape_names, blendshape_scores, strict=False))
category_scores: dict[str, float] = {}
for category, weights in CATEGORY_WEIGHTS.items():
total_weight = sum(weights.values())
weighted_sum = sum(lookup.get(name, 0.0) * w for name, w in weights.items())
category_scores[category] = weighted_sum / total_weight if total_weight > 0 else 0.0
best_category = max(CATEGORIES, key=lambda c: category_scores[c])
best_score = category_scores[best_category]
if best_score < _MIN_ACTIVATION:
return "Neutral", category_scores["Neutral"]
return best_category, best_score