"""
py-feat backend — Detectorv1 (FACS Action Units + 7-class emotion), the
most detailed but slowest of the 3 Facial Expression backends.
Deliberately Detectorv1, not the newer Detectorv2: Detectorv2's model
weights are HuggingFace-licensed "research-only, non-commercial use only";
Detectorv1's default au_model="xgb" + emotion_model="resmasknet" carry no
such restriction (only its optional identity_model="arcface" does, via
InsightFace's non-commercial terms — avoided below by passing
identity_model=None; this plugin never needs cross-frame identity/re-id).
Real-world caveats (verified against the installed py-feat==2.1.0 today,
not assumed from training-data memory — py-feat renamed Detector ->
Detectorv1 with no back-compat alias the same week this was researched):
- `import feat` unconditionally imports torchcodec at module load time
(feat/utils/io.py), which needs a torchcodec-compatible FFmpeg
(versions 4-8; a "shared"/DLL-shipping build, not a static one) at
*runtime* even though this module only ever passes single-frame
in-memory tensors, never touches video I/O. This is unrelated to and
separate from this project's own vcpkg-managed C++-side FFmpeg. If
`import feat` fails, the error will be an FFmpeg-discovery
RuntimeError, not a plain ImportError.
- Confirmed on a real dev machine (not hypothetical): having
`ffmpeg.exe` on `PATH` is NOT enough on its own — Windows Python
3.8+ removed `PATH` from the default DLL search order for security,
and torchcodec's `ctypes`-based loader needs an explicit
`os.add_dll_directory()` call to resolve its own DLL's dependency on
the real FFmpeg shared libraries. `_ensure_ffmpeg_dll_directory()`
below does this automatically (via `shutil.which("ffmpeg")`) before
`import feat` runs, so a user only needs FFmpeg findable on `PATH` at
all — but if this backend still fails to construct, that function
silently no-ops when `ffmpeg` isn't found, so `where ffmpeg`/`which
ffmpeg` is still the first thing to check.
- `feat/__init__.py` sets OMP_NUM_THREADS=1 process-wide as a side
effect (a torch/xgboost OpenMP SIGSEGV workaround) unless already
set. Harmless here since run_expression.py is its own subprocess,
but documented in case it's ever seen in logs.
- Model cache lives in site-packages/feat/resources (py-feat hardcodes
cache_dir=get_resource_path() on every download, ignoring
HF_HOME/HUGGINGFACE_HUB_CACHE) — NOT a project-local models/ dir
like the other 2 expression backends. No .gitignore entry is needed
or possible for this backend's cache.
- CPU throughput is roughly 0.1-0.8s/frame (py-feat's own published
benchmark dataset) — meaningfully slower than FER+'s near-instant
ONNX inference. Surfaced via the UI combo's tooltip, not hidden.
The exact Detectorv1() constructor kwarg names below (identity_model,
device) and the "no face found" signal on the returned Fex frame
(fex.empty / the "FaceScore" column) MUST be re-verified against the
actually-installed package before relying on this in production:
python -c "import inspect, feat; print(inspect.signature(feat.Detectorv1.__init__))"
py-feat is fast-moving and had a breaking rename (Detector -> Detectorv1)
the same day this module was written — do not trust these names as fact
without that check.
"""
from __future__ import annotations
import os
import shutil
from pathlib import Path
import numpy as np
#: The 20 py-feat/Detectorv1 xgb-head Action Units (verified against
#: feat/pretrained.py's AU_LANDMARK_MAP["Feat"]). Values are a continuous
#: [0,1] calibrated probability, NOT the classic FACS 0-5 intensity scale.
AU_NAMES: list[str] = [
"AU01",
"AU02",
"AU04",
"AU05",
"AU06",
"AU07",
"AU09",
"AU10",
"AU11",
"AU12",
"AU14",
"AU15",
"AU17",
"AU20",
"AU23",
"AU24",
"AU25",
"AU26",
"AU28",
"AU43",
]
#: py-feat's own resmasknet emotion columns (lowercase in the Fex
#: DataFrame), mapped to a Title-Case display label — matches FER+'s own
#: "Happiness"/"Sadness" capitalization. Kept independent of classifier.py's
#: differently-named CATEGORIES ("Happy"/"Sad") on purpose: each backend
#: owns its own label vocabulary, same precedent FERPLUS_LABELS already set.
_EMOTION_COLUMN_TO_LABEL: dict[str, str] = {
"anger": "Anger",
"disgust": "Disgust",
"fear": "Fear",
"happiness": "Happiness",
"sadness": "Sadness",
"surprise": "Surprise",
"neutral": "Neutral",
}
def _ensure_ffmpeg_dll_directory() -> None:
"""Registers the directory of a discoverable ``ffmpeg`` executable as a
Windows DLL search directory, before ``import feat`` runs.
Confirmed necessary on a real dev machine, not hypothetical: `import
feat` unconditionally imports torchcodec, which dynamically loads its
own ``libtorchcodec_core<N>.dll``, which in turn depends on the real
FFmpeg shared libraries (avcodec/avformat/avutil/...) matching that
FFmpeg major version. Having ``ffmpeg.exe`` on ``PATH`` alone was NOT
sufficient for that dependency chain to resolve — Windows Python 3.8+
removed the working directory and ``PATH`` from the default DLL search
order for security (see the "bpo-36085" note in the 3.8 changelog);
``os.add_dll_directory()`` is the documented replacement, and
torchcodec's own ``ctypes``-based loader needs it explicitly, a plain
``PATH`` entry is not enough. Without this, ``import feat`` fails with
a torchcodec ``RuntimeError`` ("Could not load libtorchcodec") that
reads like "no FFmpeg installed at all" even when a compatible one
genuinely is present and on PATH.
"""
if not hasattr(os, "add_dll_directory"):
return # not Windows — PATH-based DLL search still applies there
ffmpeg_path = shutil.which("ffmpeg")
if ffmpeg_path is None:
return # none found at all; let the resulting RuntimeError explain why
try:
os.add_dll_directory(str(Path(ffmpeg_path).resolve().parent))
except OSError:
pass # already registered, or a stale/invalid path — non-fatal either way
[docs]
class PyFeatClassifier:
"""py-feat Detectorv1 backend — 20-AU + 7-class emotion.
Loads/downloads Detectorv1's model weights once per process (py-feat's
own cache, outside this project's control — see module docstring).
"""
def __init__(self, device: str = "cpu") -> None:
_ensure_ffmpeg_dll_directory()
# Lazy import: keeps _fex_row_to_result() (and this whole module,
# if nothing constructs a PyFeatClassifier) importable without
# torch/torchcodec/feat installed — same reason ferplus.py defers
# `import onnxruntime` into FerPlusClassifier.__init__ rather than
# the module top.
from feat import Detectorv1
self._detector = Detectorv1(identity_model=None, device=device)
[docs]
def detect(self, face_crop_bgr: np.ndarray) -> tuple[str, float, dict[str, float]]:
"""Run AU + emotion detection on one already-cropped face image.
Parameters
----------
face_crop_bgr : numpy.ndarray
A tight crop around one detected face (e.g. via
:func:`~expression.detector.crop_bbox`) — the same
already-cropped-image contract :meth:`FerPlusClassifier.classify`
uses, so this backend can be wired into run_expression.py's
per-face dispatch identically to the FER+ backend.
Returns
-------
tuple of (str, float, dict[str, float])
``(dominant_emotion_label, dominant_score, au_values)`` —
``au_values`` keyed by :data:`AU_NAMES`, each in ``[0, 1]``.
Returns ``("Neutral", 0.0, {})`` if py-feat's own internal
face detector fails to find a face in the crop (a real, if
rare, possibility on an already-tightly-cropped image — see
the module docstring) rather than raising and aborting the
whole analysis run, the same fail-soft precedent
run_expression.py's own degenerate-bbox handling already uses.
"""
tensor = _bgr_crop_to_tensor(face_crop_bgr)
fex = self._detector.detect(tensor, data_type="tensor")
if fex.empty or fex["FaceScore"].isna().all():
return "Neutral", 0.0, {}
au_row = fex.aus.iloc[0].to_dict()
emotion_row = fex.emotions.iloc[0].to_dict()
return _fex_row_to_result(au_row, emotion_row)
def _bgr_crop_to_tensor(face_crop_bgr: np.ndarray):
"""Convert one BGR numpy face crop into the (1,C,H,W) tensor
``Detectorv1.detect(..., data_type="tensor")`` expects.
NOTE: the exact expected value range/dtype (uint8 0-255 vs. float
0-1) for ``data_type="tensor"`` must be confirmed against
Detectorv1's actual tensor-path preprocessing code at implementation
time — not invented here.
"""
import cv2
import torch
rgb = cv2.cvtColor(face_crop_bgr, cv2.COLOR_BGR2RGB)
# transpose() only permutes strides, it never copies — wrapping that
# directly in torch.from_numpy() hands torch a non-contiguous buffer,
# which many torch/torchvision internals silently assume won't happen.
# ascontiguousarray() forces a real copy into C-contiguous memory so
# this is correct regardless of what Detectorv1's internals assume.
chw = np.ascontiguousarray(rgb.transpose(2, 0, 1))
return torch.from_numpy(chw).unsqueeze(0)
[docs]
def _fex_row_to_result(
au_values: dict, emotion_values: dict
) -> tuple[str, float, dict[str, float]]:
"""Pure argmax-over-emotions + AU-passthrough — the one testable piece
of this backend, isolated from the actual Detectorv1 call exactly like
ferplus.py's ``_softmax_and_label()`` isolates softmax+argmax from the
ONNX session call.
Parameters
----------
au_values : dict of str to float
One row of py-feat's ``Fex.aus`` output, as a plain dict (e.g.
``fex.aus.iloc[0].to_dict()``).
emotion_values : dict of str to float
One row of py-feat's ``Fex.emotions`` output, as a plain dict.
Returns
-------
tuple of (str, float, dict[str, float])
``(dominant_emotion_label, dominant_score, au_values)`` —
``dominant_emotion_label`` is one of :data:`_EMOTION_COLUMN_TO_LABEL`'s
values (or the raw column name if unrecognized), ``au_values`` is
keyed exactly as passed in, values rounded to 4 decimals.
Notes
-----
NaN values (py-feat's own per-row failure marker when its internal
models can't produce a score) are treated as ``0.0`` rather than
propagating/crashing argmax — mirrors this codebase's established
"default missing/bad data to 0.0, never crash" convention (e.g. the
BLENDSHAPE_NAMES lookup-by-name-with-default on the detection side).
"""
clean_emotions = {
_EMOTION_COLUMN_TO_LABEL.get(name, name): (0.0 if _isnan(value) else float(value))
for name, value in emotion_values.items()
}
best_label = (
max(clean_emotions, key=lambda k: clean_emotions[k]) if clean_emotions else "Neutral"
)
best_score = clean_emotions.get(best_label, 0.0)
au_dict = {
name: (0.0 if _isnan(value) else round(float(value), 4))
for name, value in au_values.items()
}
return best_label, best_score, au_dict
def _isnan(value) -> bool:
return value != value # NaN-safe without importing math for one check