Files
frigate/frigate/data_processing/real_time/face.py
T

550 lines
19 KiB
Python
Raw Normal View History

2025-01-10 08:39:24 -07:00
"""Handle processing images for face detection and recognition."""
import base64
import datetime
import json
2025-01-10 08:39:24 -07:00
import logging
import os
2025-01-29 07:41:35 -07:00
import shutil
2025-06-04 20:48:26 -05:00
from pathlib import Path
2025-05-13 16:27:20 +02:00
from typing import Any, Optional
2025-01-10 08:39:24 -07:00
import cv2
import numpy as np
2025-01-18 10:52:01 -07:00
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
2025-03-10 16:29:29 -06:00
from frigate.comms.event_metadata_updater import (
EventMetadataPublisher,
EventMetadataTypeEnum,
)
from frigate.comms.inter_process import InterProcessRequestor
2025-01-10 08:39:24 -07:00
from frigate.config import FrigateConfig
2025-03-10 16:29:29 -06:00
from frigate.const import FACE_DIR, MODEL_CACHE_DIR
2025-03-25 18:59:03 -06:00
from frigate.data_processing.common.face.model import (
ArcFaceRecognizer,
FaceNetRecognizer,
2025-03-25 18:59:03 -06:00
FaceRecognizer,
)
from frigate.types import TrackedObjectUpdateTypesEnum
2025-05-09 08:36:44 -05:00
from frigate.util.builtin import EventsPerSecond, InferenceSpeed
2025-01-10 08:39:24 -07:00
from frigate.util.image import area
2025-01-10 12:44:30 -07:00
from ..types import DataProcessorMetrics
from .api import RealTimeProcessorApi
2025-01-10 08:39:24 -07:00
logger = logging.getLogger(__name__)
2025-03-17 08:05:53 -06:00
MAX_DETECTION_HEIGHT = 1080
2025-03-28 12:52:12 -06:00
MAX_FACES_ATTEMPTS_AFTER_REC = 6
MAX_FACE_ATTEMPTS = 12
2025-01-10 08:39:24 -07:00
class FaceRealTimeProcessor(RealTimeProcessorApi):
2025-03-10 16:29:29 -06:00
def __init__(
self,
config: FrigateConfig,
requestor: InterProcessRequestor,
2025-03-10 16:29:29 -06:00
sub_label_publisher: EventMetadataPublisher,
metrics: DataProcessorMetrics,
):
2025-01-10 08:39:24 -07:00
super().__init__(config, metrics)
self.face_config = config.face_recognition
self.requestor = requestor
2025-03-10 16:29:29 -06:00
self.sub_label_publisher = sub_label_publisher
2025-01-10 08:39:24 -07:00
self.face_detector: cv2.FaceDetectorYN = None
self.requires_face_detection = "face" not in self.config.objects.all_objects
2025-03-25 18:59:03 -06:00
self.person_face_history: dict[str, list[tuple[str, float, int]]] = {}
2025-05-11 13:03:53 -05:00
self.camera_current_people: dict[str, list[str]] = {}
2025-03-25 18:59:03 -06:00
self.recognizer: FaceRecognizer | None = None
self.faces_per_second = EventsPerSecond()
2025-05-09 08:36:44 -05:00
self.inference_speed = InferenceSpeed(self.metrics.face_rec_speed)
2025-01-10 08:39:24 -07:00
GITHUB_ENDPOINT = os.environ.get("GITHUB_ENDPOINT", "https://github.com")
2025-01-10 08:39:24 -07:00
download_path = os.path.join(MODEL_CACHE_DIR, "facedet")
self.model_files = {
"facedet.onnx": f"{GITHUB_ENDPOINT}/NickM-27/facenet-onnx/releases/download/v1.0/facedet.onnx",
"landmarkdet.yaml": f"{GITHUB_ENDPOINT}/NickM-27/facenet-onnx/releases/download/v1.0/landmarkdet.yaml",
2025-01-10 08:39:24 -07:00
}
if not all(
os.path.exists(os.path.join(download_path, n))
for n in self.model_files.keys()
):
# conditionally import ModelDownloader
from frigate.util.downloader import ModelDownloader
self.downloader = ModelDownloader(
model_name="facedet",
download_path=download_path,
file_names=self.model_files.keys(),
download_func=self.__download_models,
complete_func=self.__build_detector,
)
self.downloader.ensure_model_files()
else:
self.__build_detector()
self.label_map: dict[int, str] = {}
2025-03-25 18:59:03 -06:00
if self.face_config.model_size == "small":
self.recognizer = FaceNetRecognizer(self.config)
2025-03-25 18:59:03 -06:00
else:
self.recognizer = ArcFaceRecognizer(self.config)
self.recognizer.build()
2025-01-10 08:39:24 -07:00
def __download_models(self, path: str) -> None:
try:
file_name = os.path.basename(path)
# conditionally import ModelDownloader
from frigate.util.downloader import ModelDownloader
ModelDownloader.download_from_url(self.model_files[file_name], path)
except Exception as e:
logger.error(f"Failed to download {path}: {e}")
def __build_detector(self) -> None:
self.face_detector = cv2.FaceDetectorYN.create(
2025-03-01 05:35:09 +01:00
os.path.join(MODEL_CACHE_DIR, "facedet/facedet.onnx"),
2025-01-10 08:39:24 -07:00
config="",
input_size=(320, 320),
2025-03-17 08:05:53 -06:00
score_threshold=0.5,
2025-01-10 08:39:24 -07:00
nms_threshold=0.3,
)
self.faces_per_second.start()
2025-01-10 08:39:24 -07:00
2025-03-17 08:05:53 -06:00
def __detect_face(
self, input: np.ndarray, threshold: float
) -> tuple[int, int, int, int]:
2025-01-10 08:39:24 -07:00
"""Detect faces in input image."""
if not self.face_detector:
return None
2025-03-17 08:05:53 -06:00
# YN face detector fails at extreme definitions
# this rescales to a size that can properly detect faces
# still retaining plenty of detail
if input.shape[0] > MAX_DETECTION_HEIGHT:
scale_factor = MAX_DETECTION_HEIGHT / input.shape[0]
new_width = int(scale_factor * input.shape[1])
input = cv2.resize(input, (new_width, MAX_DETECTION_HEIGHT))
2025-03-17 13:50:13 -06:00
else:
scale_factor = 1
2025-03-17 08:05:53 -06:00
2025-01-10 08:39:24 -07:00
self.face_detector.setInputSize((input.shape[1], input.shape[0]))
faces = self.face_detector.detect(input)
if faces is None or faces[1] is None:
return None
face = None
for _, potential_face in enumerate(faces[1]):
2025-03-17 08:05:53 -06:00
if potential_face[-1] < threshold:
continue
2025-01-10 08:39:24 -07:00
raw_bbox = potential_face[0:4].astype(np.uint16)
2025-03-17 13:50:13 -06:00
x: int = int(max(raw_bbox[0], 0) / scale_factor)
y: int = int(max(raw_bbox[1], 0) / scale_factor)
w: int = int(raw_bbox[2] / scale_factor)
h: int = int(raw_bbox[3] / scale_factor)
2025-01-10 08:39:24 -07:00
bbox = (x, y, x + w, y + h)
if face is None or area(bbox) > area(face):
face = bbox
return face
def __update_metrics(self, duration: float) -> None:
self.faces_per_second.update()
2025-05-09 08:36:44 -05:00
self.inference_speed.update(duration)
2025-01-10 08:39:24 -07:00
2025-05-13 16:27:20 +02:00
def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray):
2025-01-10 08:39:24 -07:00
"""Look for faces in image."""
self.metrics.face_rec_fps.value = self.faces_per_second.eps()
camera = obj_data["camera"]
if not self.config.cameras[camera].face_recognition.enabled:
2025-11-03 09:05:03 -07:00
logger.debug(f"Face recognition disabled for camera {camera}, skipping")
return
2025-01-10 08:39:24 -07:00
start = datetime.datetime.now().timestamp()
id = obj_data["id"]
# don't run for non person objects
if obj_data.get("label") != "person":
logger.debug("Not processing face for a non person object.")
2025-01-10 08:39:24 -07:00
return
# don't overwrite sub label for objects that have a sub label
# that is not a face
2025-03-25 18:59:03 -06:00
if obj_data.get("sub_label") and id not in self.person_face_history:
2025-01-10 08:39:24 -07:00
logger.debug(
f"Not processing face due to existing sub label: {obj_data.get('sub_label')}."
)
return
2025-03-28 12:52:12 -06:00
# check if we have hit limits
if (
id in self.person_face_history
and len(self.person_face_history[id]) >= MAX_FACES_ATTEMPTS_AFTER_REC
):
# if we are at max attempts after rec and we have a rec
if obj_data.get("sub_label"):
logger.debug(
"Not processing due to hitting max attempts after true recognition."
)
return
# if we don't have a rec and are at max attempts
if len(self.person_face_history[id]) >= MAX_FACE_ATTEMPTS:
logger.debug("Not processing due to hitting max rec attempts.")
return
2025-05-13 16:27:20 +02:00
face: Optional[dict[str, Any]] = None
2025-01-10 08:39:24 -07:00
if self.requires_face_detection:
logger.debug("Running manual face detection.")
person_box = obj_data.get("box")
if not person_box:
2025-11-03 09:05:03 -07:00
logger.debug(f"No person box available for {id}")
2025-01-10 08:39:24 -07:00
return
rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420)
left, top, right, bottom = person_box
person = rgb[top:bottom, left:right]
2025-03-17 08:05:53 -06:00
face_box = self.__detect_face(person, self.face_config.detection_threshold)
2025-01-10 08:39:24 -07:00
if not face_box:
logger.debug("Detected no faces for person object.")
return
face_frame = person[
max(0, face_box[1]) : min(frame.shape[0], face_box[3]),
max(0, face_box[0]) : min(frame.shape[1], face_box[2]),
]
2025-03-22 12:58:27 -06:00
2025-05-13 09:27:07 -05:00
# check that face is correct size
if area(face_box) < self.config.cameras[camera].face_recognition.min_area:
logger.debug(
f"Detected face that is smaller than the min_area {face} < {self.config.cameras[camera].face_recognition.min_area}"
)
return
2025-03-22 12:58:27 -06:00
try:
face_frame = cv2.cvtColor(face_frame, cv2.COLOR_RGB2BGR)
2025-11-03 09:05:03 -07:00
except Exception as e:
logger.debug(f"Failed to convert face frame color for {id}: {e}")
2025-03-22 12:58:27 -06:00
return
2025-01-10 08:39:24 -07:00
else:
# don't run for object without attributes
if not obj_data.get("current_attributes"):
logger.debug("No attributes to parse.")
return
2025-05-13 16:27:20 +02:00
attributes: list[dict[str, Any]] = obj_data.get("current_attributes", [])
2025-01-10 08:39:24 -07:00
for attr in attributes:
if attr.get("label") != "face":
continue
if face is None or attr.get("score", 0.0) > face.get("score", 0.0):
face = attr
# no faces detected in this frame
if not face:
2025-11-03 09:05:03 -07:00
logger.debug(f"No face attributes found for {id}")
2025-01-10 08:39:24 -07:00
return
face_box = face.get("box")
# check that face is valid
if (
not face_box
or area(face_box)
< self.config.cameras[camera].face_recognition.min_area
):
2025-01-10 08:39:24 -07:00
logger.debug(f"Invalid face box {face}")
return
face_frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
face_frame = face_frame[
max(0, face_box[1]) : min(frame.shape[0], face_box[3]),
max(0, face_box[0]) : min(frame.shape[1], face_box[2]),
]
2025-03-25 18:59:03 -06:00
res = self.recognizer.classify(face_frame)
2025-01-10 08:39:24 -07:00
if not res:
2025-11-03 09:05:03 -07:00
logger.debug(f"Face recognizer returned no result for {id}")
2025-03-25 18:59:03 -06:00
self.__update_metrics(datetime.datetime.now().timestamp() - start)
2025-01-10 08:39:24 -07:00
return
sub_label, score = res
2025-03-28 12:52:12 -06:00
if score <= self.face_config.unknown_score:
2025-03-26 07:23:01 -06:00
sub_label = "unknown"
2025-01-10 08:39:24 -07:00
logger.debug(
2025-03-25 18:59:03 -06:00
f"Detected best face for person as: {sub_label} with probability {score}"
2025-01-10 08:39:24 -07:00
)
2025-03-31 15:49:56 -06:00
self.write_face_attempt(
face_frame, id, datetime.datetime.now().timestamp(), sub_label, score
)
2025-03-28 12:52:12 -06:00
2025-03-25 18:59:03 -06:00
if id not in self.person_face_history:
self.person_face_history[id] = []
2025-01-10 08:39:24 -07:00
2025-05-11 13:03:53 -05:00
if camera not in self.camera_current_people:
self.camera_current_people[camera] = []
2025-05-15 16:13:18 -06:00
self.camera_current_people[camera].append(id)
2025-03-25 18:59:03 -06:00
self.person_face_history[id].append(
(sub_label, score, face_frame.shape[0] * face_frame.shape[1])
2025-01-10 08:39:24 -07:00
)
2025-03-28 12:52:12 -06:00
(weighted_sub_label, weighted_score) = self.weighted_average(
2025-03-25 18:59:03 -06:00
self.person_face_history[id]
)
self.requestor.send_data(
"tracked_object_update",
json.dumps(
{
"type": TrackedObjectUpdateTypesEnum.face,
"name": weighted_sub_label,
"score": weighted_score,
"id": id,
"camera": camera,
"timestamp": start,
}
),
)
2025-03-25 18:59:03 -06:00
if weighted_score >= self.face_config.recognition_threshold:
self.sub_label_publisher.publish(
(id, weighted_sub_label, weighted_score),
2025-08-08 06:08:37 -06:00
EventMetadataTypeEnum.sub_label.value,
2025-03-25 18:59:03 -06:00
)
2025-01-10 08:39:24 -07:00
self.__update_metrics(datetime.datetime.now().timestamp() - start)
2025-05-13 16:27:20 +02:00
def handle_request(self, topic, request_data) -> dict[str, Any] | None:
2025-01-18 10:52:01 -07:00
if topic == EmbeddingsRequestEnum.clear_face_classifier.value:
2025-03-25 18:59:03 -06:00
self.recognizer.clear()
return {"success": True, "message": "Face classifier cleared."}
2025-03-19 09:02:25 -06:00
elif topic == EmbeddingsRequestEnum.recognize_face.value:
img = cv2.imdecode(
np.frombuffer(base64.b64decode(request_data["image"]), dtype=np.uint8),
cv2.IMREAD_COLOR,
)
# detect faces with lower confidence since we expect the face
# to be visible in uploaded images
face_box = self.__detect_face(img, 0.5)
if not face_box:
return {"message": "No face was detected.", "success": False}
face = img[face_box[1] : face_box[3], face_box[0] : face_box[2]]
2025-03-25 18:59:03 -06:00
res = self.recognizer.classify(face)
2025-03-19 09:02:25 -06:00
if not res:
return {"success": False, "message": "No face was recognized."}
sub_label, score = res
2025-03-28 12:52:12 -06:00
if score <= self.face_config.unknown_score:
sub_label = "unknown"
2025-03-19 09:02:25 -06:00
return {"success": True, "score": score, "face_name": sub_label}
2025-01-18 10:52:01 -07:00
elif topic == EmbeddingsRequestEnum.register_face.value:
label = request_data["face_name"]
2025-01-10 08:39:24 -07:00
2025-01-18 10:52:01 -07:00
if request_data.get("cropped"):
thumbnail = request_data["image"]
else:
img = cv2.imdecode(
np.frombuffer(
base64.b64decode(request_data["image"]), dtype=np.uint8
),
cv2.IMREAD_COLOR,
)
2025-03-17 08:05:53 -06:00
# detect faces with lower confidence since we expect the face
# to be visible in uploaded images
face_box = self.__detect_face(img, 0.5)
2025-01-10 08:39:24 -07:00
2025-01-18 10:52:01 -07:00
if not face_box:
return {
"message": "No face was detected.",
"success": False,
}
2025-01-10 08:39:24 -07:00
2025-01-18 10:52:01 -07:00
face = img[face_box[1] : face_box[3], face_box[0] : face_box[2]]
_, thumbnail = cv2.imencode(
".webp", face, [int(cv2.IMWRITE_WEBP_QUALITY), 100]
)
2025-01-10 08:39:24 -07:00
2025-01-18 10:52:01 -07:00
# write face to library
folder = os.path.join(FACE_DIR, label)
2025-05-09 08:36:44 -05:00
file = os.path.join(
folder, f"{label}_{datetime.datetime.now().timestamp()}.webp"
)
2025-01-18 10:52:01 -07:00
os.makedirs(folder, exist_ok=True)
2025-01-10 08:39:24 -07:00
2025-01-18 10:52:01 -07:00
# save face image
with open(file, "wb") as output:
output.write(thumbnail.tobytes())
2025-03-25 18:59:03 -06:00
self.recognizer.clear()
2025-01-18 10:52:01 -07:00
return {
"message": "Successfully registered face.",
"success": True,
}
2025-01-29 07:41:35 -07:00
elif topic == EmbeddingsRequestEnum.reprocess_face.value:
current_file: str = request_data["image_file"]
2025-03-31 15:49:56 -06:00
(id_time, id_rand, timestamp, _, _) = current_file.split("-")
2025-01-29 07:41:35 -07:00
img = None
2025-03-31 15:49:56 -06:00
id = f"{id_time}-{id_rand}"
2025-01-29 07:41:35 -07:00
if current_file:
img = cv2.imread(current_file)
if img is None:
return {
"message": "Invalid image file.",
"success": False,
}
2025-03-25 18:59:03 -06:00
res = self.recognizer.classify(img)
2025-01-29 07:41:35 -07:00
if not res:
2025-11-08 13:13:40 -07:00
return {
2025-11-17 08:12:05 -06:00
"message": "Model is still training, please try again in a few moments.",
2025-11-08 13:13:40 -07:00
"success": False,
}
2025-01-29 07:41:35 -07:00
sub_label, score = res
2025-03-28 12:52:12 -06:00
if score <= self.face_config.unknown_score:
sub_label = "unknown"
2025-04-04 07:03:08 -06:00
if "-" in sub_label:
sub_label = sub_label.replace("-", "_")
2025-01-29 07:41:35 -07:00
if self.config.face_recognition.save_attempts:
# write face to library
folder = os.path.join(FACE_DIR, "train")
2025-03-21 11:47:32 -06:00
os.makedirs(folder, exist_ok=True)
2025-01-29 07:41:35 -07:00
new_file = os.path.join(
2025-03-31 15:49:56 -06:00
folder, f"{id}-{timestamp}-{sub_label}-{score}.webp"
2025-01-29 07:41:35 -07:00
)
shutil.move(current_file, new_file)
2025-01-10 08:39:24 -07:00
2025-11-08 13:13:40 -07:00
return {
"message": f"Successfully reprocessed face. Result: {sub_label} (score: {score:.2f})",
"success": True,
"face_name": sub_label,
"score": score,
}
2025-05-11 13:03:53 -05:00
def expire_object(self, object_id: str, camera: str):
2025-03-25 18:59:03 -06:00
if object_id in self.person_face_history:
self.person_face_history.pop(object_id)
2025-03-26 07:23:01 -06:00
2025-05-11 13:03:53 -05:00
if object_id in self.camera_current_people.get(camera, []):
self.camera_current_people[camera].remove(object_id)
2025-03-28 12:52:12 -06:00
def weighted_average(
self, results_list: list[tuple[str, float, int]], max_weight: int = 4000
):
"""
Calculates a robust weighted average, capping the area weight and giving more weight to higher scores.
Args:
results_list: A list of tuples, where each tuple contains (name, score, face_area).
max_weight: The maximum weight to apply based on face area.
Returns:
A tuple containing the prominent name and its weighted average score, or (None, 0.0) if the list is empty.
"""
if not results_list:
return None, 0.0
2025-05-27 09:25:34 -06:00
counts: dict[str, int] = {}
weighted_scores: dict[str, int] = {}
total_weights: dict[str, int] = {}
2025-03-26 07:23:01 -06:00
for name, score, face_area in results_list:
2025-03-28 12:52:12 -06:00
if name == "unknown":
continue
2025-03-26 07:23:01 -06:00
if name not in weighted_scores:
2025-05-27 09:25:34 -06:00
counts[name] = 0
2025-03-26 07:23:01 -06:00
weighted_scores[name] = 0.0
2025-03-28 12:52:12 -06:00
total_weights[name] = 0.0
2025-03-26 07:23:01 -06:00
2025-05-27 09:25:34 -06:00
# increase count
counts[name] += 1
2025-03-28 12:52:12 -06:00
# Capped weight based on face area
weight = min(face_area, max_weight)
2025-03-26 07:23:01 -06:00
2025-03-28 12:52:12 -06:00
# Score-based weighting (higher scores get more weight)
weight *= (score - self.face_config.unknown_score) * 10
weighted_scores[name] += score * weight
total_weights[name] += weight
2025-03-26 07:23:01 -06:00
2025-03-28 12:52:12 -06:00
if not weighted_scores:
return None, 0.0
best_name = max(weighted_scores, key=weighted_scores.get)
2025-05-27 09:25:34 -06:00
2025-07-11 06:30:26 -06:00
# If the number of faces for this person < min_faces, we are not confident it is a correct result
if counts[best_name] < self.face_config.min_faces:
return None, 0.0
2025-05-27 09:25:34 -06:00
# If the best name has the same number of results as another name, we are not confident it is a correct result
for name, count in counts.items():
if name != best_name and counts[best_name] == count:
return None, 0.0
2025-03-28 12:52:12 -06:00
weighted_average = weighted_scores[best_name] / total_weights[best_name]
return best_name, weighted_average
2025-03-31 15:49:56 -06:00
def write_face_attempt(
self,
frame: np.ndarray,
event_id: str,
timestamp: float,
sub_label: str,
score: float,
) -> None:
if self.config.face_recognition.save_attempts:
# write face to library
folder = os.path.join(FACE_DIR, "train")
2025-04-04 07:03:08 -06:00
if "-" in sub_label:
sub_label = sub_label.replace("-", "_")
2025-03-31 15:49:56 -06:00
file = os.path.join(
folder, f"{event_id}-{timestamp}-{sub_label}-{score}.webp"
)
os.makedirs(folder, exist_ok=True)
cv2.imwrite(file, frame)
files = sorted(
filter(lambda f: (f.endswith(".webp")), os.listdir(folder)),
key=lambda f: os.path.getctime(os.path.join(folder, f)),
reverse=True,
)
# delete oldest face image if maximum is reached
if len(files) > self.config.face_recognition.save_attempts:
2025-06-04 20:48:26 -05:00
Path(os.path.join(folder, files[-1])).unlink(missing_ok=True)