blob: 67c26ba4a973d707a0898cf3172620ded6cfb452 [file]
# -------------------------------------------------------------
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
# -------------------------------------------------------------
import os
import tempfile
from contextlib import redirect_stdout
from io import StringIO
from pathlib import Path
import cv2
import numpy as np
import torch
from systemds.scuro.dataloader.video_loader import VideoStats
from systemds.scuro.drsearch.operator_registry import (
register_representation,
register_expensive_representation,
)
from systemds.scuro.modality.transformed import TransformedModality
from systemds.scuro.modality.type import ModalityType
from systemds.scuro.representations.representation import (
CONTAINER_LIST,
RepresentationStats,
)
from systemds.scuro.representations.unimodal import UnimodalRepresentation
from systemds.scuro.representations.utils import get_sequence_lengths
from systemds.scuro.utils.static_variables import (
NP_ARRAY_HEADER_BYTES,
PY_LIST_HEADER_BYTES,
PY_LIST_SLOT_BYTES,
get_device,
)
_retinaface_pretrain_patched = False
_star_dirs_patched = False
def _patch_openface_package_defaults(needs_landmarks: bool) -> None:
global _retinaface_pretrain_patched, _star_dirs_patched
if not _retinaface_pretrain_patched:
try:
from openface.Pytorch_Retinaface.data.config import cfg_mnet, cfg_re50
except ImportError:
pass
else:
cfg_mnet["pretrain"] = False
cfg_re50["pretrain"] = False
_retinaface_pretrain_patched = True
if needs_landmarks and not _star_dirs_patched:
import openface.STAR.conf.alignment as star_alignment
star_work_dir = Path(tempfile.gettempdir()) / "scuro_openface_star"
original_init = star_alignment.Alignment.__init__
def patched_init(self, args):
original_init(self, args)
self.ckpt_dir = str(star_work_dir)
self.work_dir = os.path.join(
self.ckpt_dir, self.data_definition, self.folder
)
self.model_dir = os.path.join(self.work_dir, "model")
self.log_dir = os.path.join(self.work_dir, "log")
star_alignment.Alignment.__init__ = patched_init
_star_dirs_patched = True
@register_representation([ModalityType.IMAGE, ModalityType.VIDEO])
@register_expensive_representation([ModalityType.IMAGE, ModalityType.VIDEO])
class OpenFace(UnimodalRepresentation):
supports_aggregation_pushdown = True
cache_in_worker = True
MODEL_REPOSITORY = "nutPace/openface_weights"
FACE_MODEL_FILENAME = "Alignment_RetinaFace.pth"
MULTITASK_MODEL_FILENAME = "MTL_backbone.pth"
LANDMARK_MODEL_FILENAME = "Landmark_98.pkl"
FEATURE_SETS = ("landmarks", "behavioral", "multitask", "backbone", "all")
DEFAULT_FEATURE_SET = "landmarks"
NUM_LANDMARKS = 98
BACKBONE_DIM = 1280
ACTION_UNIT_INTENSITIES = ("01", "06", "17", "25", "26", "02", "12", "15")
BEHAVIORAL_COLUMNS = (
"gaze_yaw",
"gaze_pitch",
*(f"AU{action_unit}_r" for action_unit in ACTION_UNIT_INTENSITIES),
)
EMOTION_COLUMNS = (
"emotion_neutral",
"emotion_happy",
"emotion_sad",
"emotion_surprise",
"emotion_fear",
"emotion_disgust",
"emotion_anger",
"emotion_contempt",
)
MULTITASK_COLUMNS = BEHAVIORAL_COLUMNS + EMOTION_COLUMNS
LANDMARK_COLUMNS = tuple(
coordinate
for landmark_id in range(NUM_LANDMARKS)
for coordinate in (f"landmark_{landmark_id}_x", f"landmark_{landmark_id}_y")
)
DETECTION_COLUMNS = (
"face_x1",
"face_y1",
"face_x2",
"face_y2",
"face_confidence",
*(
coordinate
for landmark_id in range(5)
for coordinate in (
f"retinaface_landmark_{landmark_id}_x",
f"retinaface_landmark_{landmark_id}_y",
)
),
)
BACKBONE_COLUMNS = tuple(
f"backbone_{feature_id}" for feature_id in range(BACKBONE_DIM)
)
FEATURE_SET_DIMS = {
"behavioral": len(BEHAVIORAL_COLUMNS),
"multitask": len(MULTITASK_COLUMNS),
"landmarks": len(MULTITASK_COLUMNS) + len(LANDMARK_COLUMNS),
"backbone": len(BACKBONE_COLUMNS),
"all": (
len(MULTITASK_COLUMNS)
+ len(DETECTION_COLUMNS)
+ len(LANDMARK_COLUMNS)
+ len(BACKBONE_COLUMNS)
),
}
FEATURE_COLUMNS = MULTITASK_COLUMNS + LANDMARK_COLUMNS
FEATURE_DIM = len(FEATURE_COLUMNS)
FACE_MODEL_MEMORY_BYTES = 8 * 1024 * 1024
MULTITASK_MODEL_MEMORY_BYTES = 128 * 1024 * 1024
LANDMARK_MODEL_MEMORY_BYTES = 192 * 1024 * 1024
CPU_RUNTIME_OVERHEAD_BYTES = 128 * 1024 * 1024
GPU_RUNTIME_OVERHEAD_BYTES = 64 * 1024 * 1024
def __init__(
self,
feature_set=DEFAULT_FEATURE_SET,
confidence_threshold=0.02,
nms_threshold=0.4,
vis_threshold=0.5,
params=None,
):
if params is not None:
feature_set = params.get("feature_set", feature_set)
confidence_threshold = params.get(
"confidence_threshold", confidence_threshold
)
nms_threshold = params.get("nms_threshold", nms_threshold)
vis_threshold = params.get("vis_threshold", vis_threshold)
parameters = {
"feature_set": list(self.FEATURE_SETS),
"confidence_threshold": [0.01, 0.02, 0.05],
"nms_threshold": [0.3, 0.4, 0.5],
"vis_threshold": [0.4, 0.5, 0.6],
}
super().__init__("OpenFace", ModalityType.EMBEDDING, parameters)
self.feature_set = feature_set
self.confidence_threshold = float(confidence_threshold)
self.nms_threshold = float(nms_threshold)
self.vis_threshold = float(vis_threshold)
self.params = params
self.data_type = np.float32
self._gpu_id = None
self.device = get_device()
self._face_detector = None
self._multitask_predictor = None
self._landmark_detector = None
self._backbone_hook = None
self._backbone_output = None
@property
def feature_set(self):
return self._feature_set
@feature_set.setter
def feature_set(self, feature_set):
if feature_set not in self.FEATURE_SETS:
raise ValueError(
f"Unknown OpenFace feature set '{feature_set}'. "
f"Expected one of: {', '.join(self.FEATURE_SETS)}"
)
self._feature_set = feature_set
@property
def feature_dim(self):
return self.FEATURE_SET_DIMS[self.feature_set]
@property
def gpu_id(self):
return self._gpu_id
@gpu_id.setter
def gpu_id(self, gpu_id):
self._gpu_id = gpu_id
self.device = get_device(gpu_id)
if self._backbone_hook is not None:
self._backbone_hook.remove()
self._face_detector = None
self._multitask_predictor = None
self._landmark_detector = None
self._backbone_hook = None
self._backbone_output = None
def get_output_stats(self, input_stats) -> RepresentationStats:
if self.params and "_pushdown_aggregation" in self.params:
return RepresentationStats(
input_stats.num_instances,
(self.feature_dim,),
aggregate_dim=None,
dtype=self.data_type,
)
if isinstance(input_stats, VideoStats):
return RepresentationStats(
input_stats.num_instances,
(input_stats.max_length, self.feature_dim),
dtype=self.data_type,
container=CONTAINER_LIST,
)
if isinstance(input_stats, RepresentationStats):
return RepresentationStats(
input_stats.num_instances,
(input_stats.output_shape[0], self.feature_dim),
dtype=self.data_type,
container=CONTAINER_LIST,
)
return RepresentationStats(
input_stats.num_instances,
(self.feature_dim,),
aggregate_dim=None,
dtype=self.data_type,
container=CONTAINER_LIST,
)
def estimate_output_memory_bytes(self, input_stats) -> int:
stats = self.get_output_stats(input_stats)
payload = int(
input_stats.num_instances
* np.prod(stats.output_shape)
* np.dtype(self.data_type).itemsize
)
return int(
PY_LIST_HEADER_BYTES
+ input_stats.num_instances * (NP_ARRAY_HEADER_BYTES + PY_LIST_SLOT_BYTES)
+ payload
)
def estimate_peak_memory_bytes(self, input_stats) -> dict:
max_height = int(getattr(input_stats, "max_height", 224))
max_width = int(getattr(input_stats, "max_width", 224))
max_channels = int(getattr(input_stats, "max_channels", 3))
frame_bytes = (
max_height * max_width * max_channels * np.dtype(np.float32).itemsize
)
model_bytes = self.FACE_MODEL_MEMORY_BYTES + self.MULTITASK_MODEL_MEMORY_BYTES
if self.feature_set in ("landmarks", "all"):
model_bytes += self.LANDMARK_MODEL_MEMORY_BYTES
output_bytes = self.estimate_output_memory_bytes(input_stats)
return {
"cpu_peak_bytes": int(
output_bytes
+ frame_bytes
+ model_bytes
+ self.CPU_RUNTIME_OVERHEAD_BYTES
),
"gpu_peak_bytes": int(
model_bytes + 2 * frame_bytes + self.GPU_RUNTIME_OVERHEAD_BYTES
),
}
def transform(self, modality, aggregation=None):
if modality.modality_type not in (ModalityType.IMAGE, ModalityType.VIDEO):
raise ValueError("OpenFace supports only image and video modalities")
is_image = modality.modality_type == ModalityType.IMAGE
if is_image:
embeddings = self._extract_image_features(modality.data)
else:
embeddings = []
lengths = get_sequence_lengths(modality.data, modality.metadata)
for owner_id, (frames, length) in enumerate(zip(modality.data, lengths)):
features = self._extract_video_features(frames[:length], owner_id)
embeddings.append(
self._aggregate(features, aggregation)
if aggregation is not None
else features
)
if aggregation is not None:
embeddings = np.stack(embeddings).astype(np.float32, copy=False)
if is_image and aggregation is not None:
embeddings = np.stack(
[
self._aggregate(feature[None, :], aggregation)
for feature in embeddings
]
).astype(np.float32, copy=False)
transformed_modality = TransformedModality(
modality, self, self.output_modality_type
)
transformed_modality.data_type = np.float32
transformed_modality.aggregate_dim = (
None if aggregation is not None or is_image else (0,)
)
transformed_modality.data = embeddings
return transformed_modality
def _extract_image_features(self, images):
return [self._extract_features(image) for image in images]
def _extract_video_features(self, frames, owner_id):
if len(frames) == 0:
raise ValueError(f"Video instance {owner_id} contains no frames")
return np.stack([self._extract_features(frame) for frame in frames])
def _extract_features(self, image):
self._load_models()
image_bgr = self._to_bgr(image)
face, detections = self._face_detector.get_face(image_bgr)
if face is None or face.size == 0:
return np.zeros(self.feature_dim, dtype=np.float32)
needs_backbone = self.feature_set in ("backbone", "all")
if needs_backbone:
self._ensure_backbone_hook()
self._backbone_output = None
emotion_output, gaze_output, action_unit_output = (
self._multitask_predictor.predict(face)
)
behavioral = self._behavioral_features(gaze_output, action_unit_output)
emotion = self._checked_vector(
emotion_output, len(self.EMOTION_COLUMNS), "emotion"
)
multitask = np.concatenate((behavioral, emotion))
if self.feature_set == "behavioral":
return behavioral
if self.feature_set == "multitask":
return multitask.astype(np.float32, copy=False)
backbone = None
if needs_backbone:
backbone = self._checked_vector(
self._backbone_output, self.BACKBONE_DIM, "backbone"
)
if self.feature_set == "backbone":
return backbone
landmarks = self._landmark_features(image_bgr, detections)
if self.feature_set == "landmarks":
return np.concatenate((multitask, landmarks)).astype(np.float32, copy=False)
detection = self._detection_features(detections, image_bgr.shape)
return np.concatenate((multitask, detection, landmarks, backbone)).astype(
np.float32, copy=False
)
def _behavioral_features(self, gaze_output, action_unit_output):
gaze = self._checked_vector(gaze_output, 2, "gaze")
action_units = self._checked_vector(
action_unit_output, len(self.ACTION_UNIT_INTENSITIES), "action-unit"
)
return np.concatenate((gaze, action_units)).astype(np.float32, copy=False)
def _landmark_features(self, image_bgr, detections):
with redirect_stdout(StringIO()):
landmarks = self._landmark_detector.detect_landmarks(
image_bgr,
detections[:1],
confidence_threshold=self.vis_threshold,
)
if not landmarks:
return np.zeros(len(self.LANDMARK_COLUMNS), dtype=np.float32)
points = np.asarray(landmarks[0], dtype=np.float32)
if points.shape != (self.NUM_LANDMARKS, 2):
raise RuntimeError(
"OpenFace 3.0 returned unexpected landmark dimensions: "
f"{points.shape}"
)
height, width = image_bgr.shape[:2]
points = points.copy()
points[:, 0] /= width
points[:, 1] /= height
return points.reshape(-1)
@classmethod
def _detection_features(cls, detections, image_shape):
detection = np.asarray(detections[0], dtype=np.float32)
if detection.size < len(cls.DETECTION_COLUMNS):
raise RuntimeError(
"OpenFace 3.0 returned unexpected face-detection dimensions: "
f"{detection.size}"
)
detection = detection[: len(cls.DETECTION_COLUMNS)].copy()
height, width = image_shape[:2]
detection[[0, 2, 5, 7, 9, 11, 13]] /= width
detection[[1, 3, 6, 8, 10, 12, 14]] /= height
return detection
def _ensure_backbone_hook(self):
if self._backbone_hook is not None:
return
def capture_backbone(_module, _inputs, output):
self._backbone_output = output
self._backbone_hook = (
self._multitask_predictor.model.base_model.register_forward_hook(
capture_backbone
)
)
def _load_models(self):
needs_landmarks = self.feature_set in ("landmarks", "all")
models_ready = (
self._face_detector is not None
and self._multitask_predictor is not None
and (not needs_landmarks or self._landmark_detector is not None)
)
if models_ready:
return
try:
from huggingface_hub import snapshot_download
from openface.face_detection import FaceDetector
from openface.multitask_model import MultitaskPredictor
if needs_landmarks:
from openface.landmark_detection import LandmarkDetector
except ImportError as error:
raise ImportError(
"OpenFace 3.0 is required for this representation. "
"Install it with 'pip install openface-test'."
) from error
_patch_openface_package_defaults(needs_landmarks)
weight_files = [self.FACE_MODEL_FILENAME, self.MULTITASK_MODEL_FILENAME]
if needs_landmarks:
weight_files.append(self.LANDMARK_MODEL_FILENAME)
weights_directory = Path(
snapshot_download(
repo_id=self.MODEL_REPOSITORY,
allow_patterns=weight_files,
)
)
class ArrayFaceDetector(FaceDetector):
def preprocess_image(self, image, resize=1.0):
image_raw = np.ascontiguousarray(image)
detector_input = np.float32(image_raw)
if resize != 1:
detector_input = cv2.resize(
detector_input,
None,
fx=resize,
fy=resize,
interpolation=cv2.INTER_LINEAR,
)
detector_input -= (104, 117, 123)
detector_input = detector_input.transpose(2, 0, 1)
detector_input = (
torch.from_numpy(detector_input).unsqueeze(0).to(self.device)
)
return detector_input, image_raw
device = str(self.device)
if self._face_detector is None:
self._face_detector = ArrayFaceDetector(
model_path=str(weights_directory / self.FACE_MODEL_FILENAME),
device=device,
confidence_threshold=self.confidence_threshold,
nms_threshold=self.nms_threshold,
vis_threshold=self.vis_threshold,
)
if self._multitask_predictor is None:
self._multitask_predictor = MultitaskPredictor(
model_path=str(weights_directory / self.MULTITASK_MODEL_FILENAME),
device=device,
)
if needs_landmarks and self._landmark_detector is None:
landmark_device = self.device.type
device_ids = (
[-1]
if landmark_device == "cpu"
else [self.device.index if self.device.index is not None else 0]
)
self._landmark_detector = LandmarkDetector(
model_path=str(weights_directory / self.LANDMARK_MODEL_FILENAME),
device=landmark_device,
device_ids=device_ids,
)
@staticmethod
def _checked_vector(output, expected_size, output_name):
if output is None:
size = 0
else:
if hasattr(output, "detach"):
output = output.detach().cpu().numpy()
output = np.asarray(output, dtype=np.float32).reshape(-1)
size = output.size
if size != expected_size:
raise RuntimeError(
f"OpenFace 3.0 returned unexpected {output_name} dimensions: {size}"
)
return output
@staticmethod
def _aggregate(features, aggregation):
return np.asarray(aggregation.execute(features), dtype=np.float32)
@staticmethod
def _to_bgr(image):
if hasattr(image, "detach"):
image = image.detach().cpu().numpy()
image = np.asarray(image)
if image.ndim not in (2, 3):
raise ValueError(
f"Expected an image tensor with 2 or 3 dimensions, got {image.shape}"
)
if np.issubdtype(image.dtype, np.floating):
finite = image[np.isfinite(image)]
if finite.size and finite.min() >= 0.0 and finite.max() <= 1.0:
image = image * 255.0
image = np.nan_to_num(image, nan=0.0, posinf=255.0, neginf=0.0)
image = np.clip(image, 0, 255).astype(np.uint8, copy=False)
if image.ndim == 2:
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
elif image.shape[2] == 1:
image = cv2.cvtColor(image[:, :, 0], cv2.COLOR_GRAY2BGR)
elif image.shape[2] == 3:
image = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
elif image.shape[2] == 4:
image = cv2.cvtColor(image, cv2.COLOR_RGBA2BGR)
else:
raise ValueError(
f"Expected 1, 3, or 4 image channels, got {image.shape[2]}"
)
return np.ascontiguousarray(image)