mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-04-24 01:16:47 +02:00
Improve face recognition (#15205)
* Validate faces using cosine distance and SVC * Formatting * Use opencv instead of face embedding * Update docs for training data * Adjust to score system * Set bounds * remove face embeddings * Update writing images * Add face library page * Add ability to select file * Install opencv deps * Cleanup * Use different deps * Move deps * Cleanup * Only show face library for desktop * Implement deleting * Add ability to upload image * Add support for uploading images
This commit is contained in:
parent
dd7b1be7f4
commit
0e4ff91d6b
@ -16,7 +16,9 @@ apt-get -qq install --no-install-recommends -y \
|
|||||||
curl \
|
curl \
|
||||||
lsof \
|
lsof \
|
||||||
jq \
|
jq \
|
||||||
nethogs
|
nethogs \
|
||||||
|
libgl1 \
|
||||||
|
libglib2.0-0
|
||||||
|
|
||||||
# ensure python3 defaults to python3.9
|
# ensure python3 defaults to python3.9
|
||||||
update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.9 1
|
update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.9 1
|
||||||
|
@ -34,8 +34,8 @@ unidecode == 1.3.*
|
|||||||
# Image Manipulation
|
# Image Manipulation
|
||||||
numpy == 1.26.*
|
numpy == 1.26.*
|
||||||
opencv-python-headless == 4.9.0.*
|
opencv-python-headless == 4.9.0.*
|
||||||
|
opencv-contrib-python == 4.9.0.*
|
||||||
scipy == 1.13.*
|
scipy == 1.13.*
|
||||||
scikit-learn == 1.5.*
|
|
||||||
# OpenVino & ONNX
|
# OpenVino & ONNX
|
||||||
openvino == 2024.3.*
|
openvino == 2024.3.*
|
||||||
onnxruntime-openvino == 1.19.* ; platform_machine == 'x86_64'
|
onnxruntime-openvino == 1.19.* ; platform_machine == 'x86_64'
|
||||||
|
@ -19,3 +19,17 @@ Face recognition is disabled by default and requires semantic search to be enabl
|
|||||||
face_recognition:
|
face_recognition:
|
||||||
enabled: true
|
enabled: true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Dataset
|
||||||
|
|
||||||
|
The number of images needed for a sufficient training set for face recognition varies depending on several factors:
|
||||||
|
|
||||||
|
- Complexity of the task: A simple task like recognizing faces of known individuals may require fewer images than a complex task like identifying unknown individuals in a large crowd.
|
||||||
|
- Diversity of the dataset: A dataset with diverse images, including variations in lighting, pose, and facial expressions, will require fewer images per person than a less diverse dataset.
|
||||||
|
- Desired accuracy: The higher the desired accuracy, the more images are typically needed.
|
||||||
|
|
||||||
|
However, here are some general guidelines:
|
||||||
|
|
||||||
|
- Minimum: For basic face recognition tasks, a minimum of 10-20 images per person is often recommended.
|
||||||
|
- Recommended: For more robust and accurate systems, 30-50 images per person is a good starting point.
|
||||||
|
- Ideal: For optimal performance, especially in challenging conditions, 100 or more images per person can be beneficial.
|
@ -1,11 +1,14 @@
|
|||||||
"""Object classification APIs."""
|
"""Object classification APIs."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, UploadFile
|
from fastapi import APIRouter, Request, UploadFile
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from pathvalidate import sanitize_filename
|
||||||
|
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
|
from frigate.const import FACE_DIR
|
||||||
from frigate.embeddings import EmbeddingsContext
|
from frigate.embeddings import EmbeddingsContext
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -15,20 +18,18 @@ router = APIRouter(tags=[Tags.events])
|
|||||||
|
|
||||||
@router.get("/faces")
|
@router.get("/faces")
|
||||||
def get_faces():
|
def get_faces():
|
||||||
return JSONResponse(content={"message": "there are faces"})
|
face_dict: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
for name in os.listdir(FACE_DIR):
|
||||||
|
face_dict[name] = []
|
||||||
|
for file in os.listdir(os.path.join(FACE_DIR, name)):
|
||||||
|
face_dict[name].append(file)
|
||||||
|
|
||||||
|
return JSONResponse(status_code=200, content=face_dict)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/faces/{name}")
|
@router.post("/faces/{name}")
|
||||||
async def register_face(request: Request, name: str, file: UploadFile):
|
async def register_face(request: Request, name: str, file: UploadFile):
|
||||||
# if not file.content_type.startswith("image"):
|
|
||||||
# return JSONResponse(
|
|
||||||
# status_code=400,
|
|
||||||
# content={
|
|
||||||
# "success": False,
|
|
||||||
# "message": "Only an image can be used to register a face.",
|
|
||||||
# },
|
|
||||||
# )
|
|
||||||
|
|
||||||
context: EmbeddingsContext = request.app.embeddings
|
context: EmbeddingsContext = request.app.embeddings
|
||||||
context.register_face(name, await file.read())
|
context.register_face(name, await file.read())
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@ -37,8 +38,8 @@ async def register_face(request: Request, name: str, file: UploadFile):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/faces")
|
@router.post("/faces/{name}/delete")
|
||||||
def deregister_faces(request: Request, body: dict = None):
|
def deregister_faces(request: Request, name: str, body: dict = None):
|
||||||
json: dict[str, any] = body or {}
|
json: dict[str, any] = body or {}
|
||||||
list_of_ids = json.get("ids", "")
|
list_of_ids = json.get("ids", "")
|
||||||
|
|
||||||
@ -49,7 +50,9 @@ def deregister_faces(request: Request, body: dict = None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
context: EmbeddingsContext = request.app.embeddings
|
context: EmbeddingsContext = request.app.embeddings
|
||||||
context.delete_face_ids(list_of_ids)
|
context.delete_face_ids(
|
||||||
|
name, map(lambda file: sanitize_filename(file), list_of_ids)
|
||||||
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=({"success": True, "message": "Successfully deleted faces."}),
|
content=({"success": True, "message": "Successfully deleted faces."}),
|
||||||
status_code=200,
|
status_code=200,
|
||||||
|
@ -24,7 +24,10 @@ class SemanticSearchConfig(FrigateBaseModel):
|
|||||||
class FaceRecognitionConfig(FrigateBaseModel):
|
class FaceRecognitionConfig(FrigateBaseModel):
|
||||||
enabled: bool = Field(default=False, title="Enable face recognition.")
|
enabled: bool = Field(default=False, title="Enable face recognition.")
|
||||||
threshold: float = Field(
|
threshold: float = Field(
|
||||||
default=0.9, title="Face similarity score required to be considered a match."
|
default=170,
|
||||||
|
title="minimum face distance score required to be considered a match.",
|
||||||
|
gt=0.0,
|
||||||
|
le=1.0,
|
||||||
)
|
)
|
||||||
min_area: int = Field(
|
min_area: int = Field(
|
||||||
default=500, title="Min area of face box to consider running face recognition."
|
default=500, title="Min area of face box to consider running face recognition."
|
||||||
|
@ -29,10 +29,6 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
|||||||
ids = ",".join(["?" for _ in event_ids])
|
ids = ",".join(["?" for _ in event_ids])
|
||||||
self.execute_sql(f"DELETE FROM vec_descriptions WHERE id IN ({ids})", event_ids)
|
self.execute_sql(f"DELETE FROM vec_descriptions WHERE id IN ({ids})", event_ids)
|
||||||
|
|
||||||
def delete_embeddings_face(self, face_ids: list[str]) -> None:
|
|
||||||
ids = ",".join(["?" for _ in face_ids])
|
|
||||||
self.execute_sql(f"DELETE FROM vec_faces WHERE id IN ({ids})", face_ids)
|
|
||||||
|
|
||||||
def drop_embeddings_tables(self) -> None:
|
def drop_embeddings_tables(self) -> None:
|
||||||
self.execute_sql("""
|
self.execute_sql("""
|
||||||
DROP TABLE vec_descriptions;
|
DROP TABLE vec_descriptions;
|
||||||
@ -40,11 +36,8 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
|||||||
self.execute_sql("""
|
self.execute_sql("""
|
||||||
DROP TABLE vec_thumbnails;
|
DROP TABLE vec_thumbnails;
|
||||||
""")
|
""")
|
||||||
self.execute_sql("""
|
|
||||||
DROP TABLE vec_faces;
|
|
||||||
""")
|
|
||||||
|
|
||||||
def create_embeddings_tables(self, face_recognition: bool) -> None:
|
def create_embeddings_tables(self) -> None:
|
||||||
"""Create vec0 virtual table for embeddings"""
|
"""Create vec0 virtual table for embeddings"""
|
||||||
self.execute_sql("""
|
self.execute_sql("""
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS vec_thumbnails USING vec0(
|
CREATE VIRTUAL TABLE IF NOT EXISTS vec_thumbnails USING vec0(
|
||||||
@ -58,11 +51,3 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
|||||||
description_embedding FLOAT[768] distance_metric=cosine
|
description_embedding FLOAT[768] distance_metric=cosine
|
||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
if face_recognition:
|
|
||||||
self.execute_sql("""
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS vec_faces USING vec0(
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
face_embedding FLOAT[512] distance_metric=cosine
|
|
||||||
);
|
|
||||||
""")
|
|
||||||
|
@ -14,7 +14,7 @@ from setproctitle import setproctitle
|
|||||||
|
|
||||||
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum, EmbeddingsRequestor
|
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum, EmbeddingsRequestor
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.const import CONFIG_DIR
|
from frigate.const import CONFIG_DIR, FACE_DIR
|
||||||
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||||
from frigate.models import Event
|
from frigate.models import Event
|
||||||
from frigate.util.builtin import serialize
|
from frigate.util.builtin import serialize
|
||||||
@ -209,8 +209,13 @@ class EmbeddingsContext:
|
|||||||
|
|
||||||
return self.db.execute_sql(sql_query).fetchall()
|
return self.db.execute_sql(sql_query).fetchall()
|
||||||
|
|
||||||
def delete_face_ids(self, ids: list[str]) -> None:
|
def delete_face_ids(self, face: str, ids: list[str]) -> None:
|
||||||
self.db.delete_embeddings_face(ids)
|
folder = os.path.join(FACE_DIR, face)
|
||||||
|
for id in ids:
|
||||||
|
file_path = os.path.join(folder, id)
|
||||||
|
|
||||||
|
if os.path.isfile(file_path):
|
||||||
|
os.unlink(file_path)
|
||||||
|
|
||||||
def update_description(self, event_id: str, description: str) -> None:
|
def update_description(self, event_id: str, description: str) -> None:
|
||||||
self.requestor.send_data(
|
self.requestor.send_data(
|
||||||
|
@ -3,8 +3,6 @@
|
|||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import random
|
|
||||||
import string
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from numpy import ndarray
|
from numpy import ndarray
|
||||||
@ -14,7 +12,6 @@ from frigate.comms.inter_process import InterProcessRequestor
|
|||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.const import (
|
from frigate.const import (
|
||||||
CONFIG_DIR,
|
CONFIG_DIR,
|
||||||
FACE_DIR,
|
|
||||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||||
UPDATE_MODEL_STATE,
|
UPDATE_MODEL_STATE,
|
||||||
)
|
)
|
||||||
@ -68,7 +65,7 @@ class Embeddings:
|
|||||||
self.requestor = InterProcessRequestor()
|
self.requestor = InterProcessRequestor()
|
||||||
|
|
||||||
# Create tables if they don't exist
|
# Create tables if they don't exist
|
||||||
self.db.create_embeddings_tables(self.config.face_recognition.enabled)
|
self.db.create_embeddings_tables()
|
||||||
|
|
||||||
models = [
|
models = [
|
||||||
"jinaai/jina-clip-v1-text_model_fp16.onnx",
|
"jinaai/jina-clip-v1-text_model_fp16.onnx",
|
||||||
@ -126,22 +123,6 @@ class Embeddings:
|
|||||||
device="GPU" if config.semantic_search.model_size == "large" else "CPU",
|
device="GPU" if config.semantic_search.model_size == "large" else "CPU",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.face_embedding = None
|
|
||||||
|
|
||||||
if self.config.face_recognition.enabled:
|
|
||||||
self.face_embedding = GenericONNXEmbedding(
|
|
||||||
model_name="facenet",
|
|
||||||
model_file="facenet.onnx",
|
|
||||||
download_urls={
|
|
||||||
"facenet.onnx": "https://github.com/NickM-27/facenet-onnx/releases/download/v1.0/facenet.onnx",
|
|
||||||
"facedet.onnx": "https://github.com/opencv/opencv_zoo/raw/refs/heads/main/models/face_detection_yunet/face_detection_yunet_2023mar_int8.onnx",
|
|
||||||
},
|
|
||||||
model_size="large",
|
|
||||||
model_type=ModelTypeEnum.face,
|
|
||||||
requestor=self.requestor,
|
|
||||||
device="GPU",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.lpr_detection_model = None
|
self.lpr_detection_model = None
|
||||||
self.lpr_classification_model = None
|
self.lpr_classification_model = None
|
||||||
self.lpr_recognition_model = None
|
self.lpr_recognition_model = None
|
||||||
@ -277,40 +258,12 @@ class Embeddings:
|
|||||||
|
|
||||||
return embeddings
|
return embeddings
|
||||||
|
|
||||||
def embed_face(self, label: str, thumbnail: bytes, upsert: bool = False) -> ndarray:
|
|
||||||
embedding = self.face_embedding(thumbnail)[0]
|
|
||||||
|
|
||||||
if upsert:
|
|
||||||
rand_id = "".join(
|
|
||||||
random.choices(string.ascii_lowercase + string.digits, k=6)
|
|
||||||
)
|
|
||||||
id = f"{label}-{rand_id}"
|
|
||||||
|
|
||||||
# write face to library
|
|
||||||
folder = os.path.join(FACE_DIR, label)
|
|
||||||
file = os.path.join(folder, f"{id}.webp")
|
|
||||||
os.makedirs(folder, exist_ok=True)
|
|
||||||
|
|
||||||
# save face image
|
|
||||||
with open(file, "wb") as output:
|
|
||||||
output.write(thumbnail)
|
|
||||||
|
|
||||||
self.db.execute_sql(
|
|
||||||
"""
|
|
||||||
INSERT OR REPLACE INTO vec_faces(id, face_embedding)
|
|
||||||
VALUES(?, ?)
|
|
||||||
""",
|
|
||||||
(id, serialize(embedding)),
|
|
||||||
)
|
|
||||||
|
|
||||||
return embedding
|
|
||||||
|
|
||||||
def reindex(self) -> None:
|
def reindex(self) -> None:
|
||||||
logger.info("Indexing tracked object embeddings...")
|
logger.info("Indexing tracked object embeddings...")
|
||||||
|
|
||||||
self.db.drop_embeddings_tables()
|
self.db.drop_embeddings_tables()
|
||||||
logger.debug("Dropped embeddings tables.")
|
logger.debug("Dropped embeddings tables.")
|
||||||
self.db.create_embeddings_tables(self.config.face_recognition.enabled)
|
self.db.create_embeddings_tables()
|
||||||
logger.debug("Created embeddings tables.")
|
logger.debug("Created embeddings tables.")
|
||||||
|
|
||||||
# Delete the saved stats file
|
# Delete the saved stats file
|
||||||
|
@ -3,7 +3,9 @@
|
|||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
import re
|
import re
|
||||||
|
import string
|
||||||
import threading
|
import threading
|
||||||
from multiprocessing.synchronize import Event as MpEvent
|
from multiprocessing.synchronize import Event as MpEvent
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -23,7 +25,12 @@ from frigate.comms.event_metadata_updater import (
|
|||||||
from frigate.comms.events_updater import EventEndSubscriber, EventUpdateSubscriber
|
from frigate.comms.events_updater import EventEndSubscriber, EventUpdateSubscriber
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.const import CLIPS_DIR, FRIGATE_LOCALHOST, UPDATE_EVENT_DESCRIPTION
|
from frigate.const import (
|
||||||
|
CLIPS_DIR,
|
||||||
|
FACE_DIR,
|
||||||
|
FRIGATE_LOCALHOST,
|
||||||
|
UPDATE_EVENT_DESCRIPTION,
|
||||||
|
)
|
||||||
from frigate.embeddings.lpr.lpr import LicensePlateRecognition
|
from frigate.embeddings.lpr.lpr import LicensePlateRecognition
|
||||||
from frigate.events.types import EventTypeEnum
|
from frigate.events.types import EventTypeEnum
|
||||||
from frigate.genai import get_genai_client
|
from frigate.genai import get_genai_client
|
||||||
@ -70,7 +77,9 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
self.requires_face_detection = "face" not in self.config.objects.all_objects
|
self.requires_face_detection = "face" not in self.config.objects.all_objects
|
||||||
self.detected_faces: dict[str, float] = {}
|
self.detected_faces: dict[str, float] = {}
|
||||||
self.face_classifier = (
|
self.face_classifier = (
|
||||||
FaceClassificationModel(db) if self.face_recognition_enabled else None
|
FaceClassificationModel(self.config.face_recognition, db)
|
||||||
|
if self.face_recognition_enabled
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
# create communication for updating event descriptions
|
# create communication for updating event descriptions
|
||||||
@ -145,12 +154,14 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
if not self.face_recognition_enabled:
|
if not self.face_recognition_enabled:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
rand_id = "".join(
|
||||||
|
random.choices(string.ascii_lowercase + string.digits, k=6)
|
||||||
|
)
|
||||||
|
label = data["face_name"]
|
||||||
|
id = f"{label}-{rand_id}"
|
||||||
|
|
||||||
if data.get("cropped"):
|
if data.get("cropped"):
|
||||||
self.embeddings.embed_face(
|
pass
|
||||||
data["face_name"],
|
|
||||||
base64.b64decode(data["image"]),
|
|
||||||
upsert=True,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
img = cv2.imdecode(
|
img = cv2.imdecode(
|
||||||
np.frombuffer(
|
np.frombuffer(
|
||||||
@ -164,12 +175,18 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
face = img[face_box[1] : face_box[3], face_box[0] : face_box[2]]
|
face = img[face_box[1] : face_box[3], face_box[0] : face_box[2]]
|
||||||
ret, webp = cv2.imencode(
|
ret, thumbnail = cv2.imencode(
|
||||||
".webp", face, [int(cv2.IMWRITE_WEBP_QUALITY), 100]
|
".webp", face, [int(cv2.IMWRITE_WEBP_QUALITY), 100]
|
||||||
)
|
)
|
||||||
self.embeddings.embed_face(
|
|
||||||
data["face_name"], webp.tobytes(), upsert=True
|
# write face to library
|
||||||
)
|
folder = os.path.join(FACE_DIR, label)
|
||||||
|
file = os.path.join(folder, f"{id}.webp")
|
||||||
|
os.makedirs(folder, exist_ok=True)
|
||||||
|
|
||||||
|
# save face image
|
||||||
|
with open(file, "wb") as output:
|
||||||
|
output.write(thumbnail.tobytes())
|
||||||
|
|
||||||
self.face_classifier.clear_classifier()
|
self.face_classifier.clear_classifier()
|
||||||
return True
|
return True
|
||||||
@ -202,7 +219,9 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
|
|
||||||
# Create our own thumbnail based on the bounding box and the frame time
|
# Create our own thumbnail based on the bounding box and the frame time
|
||||||
try:
|
try:
|
||||||
yuv_frame = self.frame_manager.get(frame_name, camera_config.frame_shape_yuv)
|
yuv_frame = self.frame_manager.get(
|
||||||
|
frame_name, camera_config.frame_shape_yuv
|
||||||
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -479,16 +498,7 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
ret, webp = cv2.imencode(
|
res = self.face_classifier.classify_face(face_frame)
|
||||||
".webp", face_frame, [int(cv2.IMWRITE_WEBP_QUALITY), 100]
|
|
||||||
)
|
|
||||||
|
|
||||||
if not ret:
|
|
||||||
logger.debug("Not processing face due to error creating cropped image.")
|
|
||||||
return
|
|
||||||
|
|
||||||
embedding = self.embeddings.embed_face("unknown", webp.tobytes(), upsert=False)
|
|
||||||
res = self.face_classifier.classify_face(embedding)
|
|
||||||
|
|
||||||
if not res:
|
if not res:
|
||||||
return
|
return
|
||||||
@ -499,11 +509,9 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
f"Detected best face for person as: {sub_label} with score {score}"
|
f"Detected best face for person as: {sub_label} with score {score}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if score < self.config.face_recognition.threshold or (
|
if id in self.detected_faces and score <= self.detected_faces[id]:
|
||||||
id in self.detected_faces and score <= self.detected_faces[id]
|
|
||||||
):
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Recognized face score {score} is less than threshold ({self.config.face_recognition.threshold}) / previous face score ({self.detected_faces.get(id)})."
|
f"Recognized face distance {score} is less than previous face distance ({self.detected_faces.get(id)})."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -4,13 +4,12 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import onnxruntime as ort
|
import onnxruntime as ort
|
||||||
from playhouse.sqliteq import SqliteQueueDatabase
|
from playhouse.sqliteq import SqliteQueueDatabase
|
||||||
from sklearn.preprocessing import LabelEncoder, Normalizer
|
|
||||||
from sklearn.svm import SVC
|
|
||||||
|
|
||||||
from frigate.util.builtin import deserialize
|
from frigate.config.semantic_search import FaceRecognitionConfig
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import openvino as ov
|
import openvino as ov
|
||||||
@ -21,6 +20,9 @@ except ImportError:
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
MIN_MATCHING_FACES = 2
|
||||||
|
|
||||||
|
|
||||||
def get_ort_providers(
|
def get_ort_providers(
|
||||||
force_cpu: bool = False, device: str = "AUTO", requires_fp16: bool = False
|
force_cpu: bool = False, device: str = "AUTO", requires_fp16: bool = False
|
||||||
) -> tuple[list[str], list[dict[str, any]]]:
|
) -> tuple[list[str], list[dict[str, any]]]:
|
||||||
@ -157,38 +159,42 @@ class ONNXModelRunner:
|
|||||||
|
|
||||||
|
|
||||||
class FaceClassificationModel:
|
class FaceClassificationModel:
|
||||||
def __init__(self, db: SqliteQueueDatabase):
|
def __init__(self, config: FaceRecognitionConfig, db: SqliteQueueDatabase):
|
||||||
|
self.config = config
|
||||||
self.db = db
|
self.db = db
|
||||||
self.labeler: Optional[LabelEncoder] = None
|
self.recognizer = cv2.face.LBPHFaceRecognizer_create(radius=4, threshold=(1 - config.threshold) * 1000)
|
||||||
self.classifier: Optional[SVC] = None
|
self.label_map: dict[int, str] = {}
|
||||||
|
|
||||||
def __build_classifier(self) -> None:
|
def __build_classifier(self) -> None:
|
||||||
faces: list[tuple[str, bytes]] = self.db.execute_sql(
|
labels = []
|
||||||
"SELECT id, face_embedding FROM vec_faces"
|
faces = []
|
||||||
).fetchall()
|
|
||||||
embeddings = np.array([deserialize(f[1]) for f in faces])
|
dir = "/media/frigate/clips/faces"
|
||||||
self.labeler = LabelEncoder()
|
for idx, name in enumerate(os.listdir(dir)):
|
||||||
norms = Normalizer(norm="l2").transform(embeddings)
|
self.label_map[idx] = name
|
||||||
labels = self.labeler.fit_transform([f[0].split("-")[0] for f in faces])
|
face_folder = os.path.join(dir, name)
|
||||||
self.classifier = SVC(kernel="linear", probability=True)
|
for image in os.listdir(face_folder):
|
||||||
self.classifier.fit(norms, labels)
|
img = cv2.imread(os.path.join(face_folder, image))
|
||||||
|
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||||
|
equ = cv2.equalizeHist(gray)
|
||||||
|
faces.append(equ)
|
||||||
|
labels.append(idx)
|
||||||
|
|
||||||
|
self.recognizer.train(faces, np.array(labels))
|
||||||
|
|
||||||
def clear_classifier(self) -> None:
|
def clear_classifier(self) -> None:
|
||||||
self.classifier = None
|
self.classifier = None
|
||||||
self.labeler = None
|
self.labeler = None
|
||||||
|
|
||||||
def classify_face(self, embedding: np.ndarray) -> Optional[tuple[str, float]]:
|
def classify_face(self, face_image: np.ndarray) -> Optional[tuple[str, float]]:
|
||||||
if not self.classifier:
|
if not self.label_map:
|
||||||
self.__build_classifier()
|
self.__build_classifier()
|
||||||
|
|
||||||
res = self.classifier.predict([embedding])
|
index, distance = self.recognizer.predict(cv2.equalizeHist(cv2.cvtColor(face_image, cv2.COLOR_BGR2GRAY)))
|
||||||
|
|
||||||
if res is None:
|
if index == -1:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
label = res[0]
|
score = 1.0 - (distance / 1000)
|
||||||
probabilities = self.classifier.predict_proba([embedding])[0]
|
return self.label_map[index], round(score, 2)
|
||||||
return (
|
|
||||||
self.labeler.inverse_transform([label])[0],
|
|
||||||
round(probabilities[label], 2),
|
|
||||||
)
|
|
||||||
|
@ -19,6 +19,7 @@ const ConfigEditor = lazy(() => import("@/pages/ConfigEditor"));
|
|||||||
const System = lazy(() => import("@/pages/System"));
|
const System = lazy(() => import("@/pages/System"));
|
||||||
const Settings = lazy(() => import("@/pages/Settings"));
|
const Settings = lazy(() => import("@/pages/Settings"));
|
||||||
const UIPlayground = lazy(() => import("@/pages/UIPlayground"));
|
const UIPlayground = lazy(() => import("@/pages/UIPlayground"));
|
||||||
|
const FaceLibrary = lazy(() => import("@/pages/FaceLibrary"));
|
||||||
const Logs = lazy(() => import("@/pages/Logs"));
|
const Logs = lazy(() => import("@/pages/Logs"));
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@ -51,6 +52,7 @@ function App() {
|
|||||||
<Route path="/config" element={<ConfigEditor />} />
|
<Route path="/config" element={<ConfigEditor />} />
|
||||||
<Route path="/logs" element={<Logs />} />
|
<Route path="/logs" element={<Logs />} />
|
||||||
<Route path="/playground" element={<UIPlayground />} />
|
<Route path="/playground" element={<UIPlayground />} />
|
||||||
|
<Route path="/faces" element={<FaceLibrary />} />
|
||||||
<Route path="*" element={<Redirect to="/" />} />
|
<Route path="*" element={<Redirect to="/" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
88
web/src/components/overlay/dialog/UploadImageDialog.tsx
Normal file
88
web/src/components/overlay/dialog/UploadImageDialog.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
type UploadImageDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
onSave: (file: File) => void;
|
||||||
|
};
|
||||||
|
export default function UploadImageDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
setOpen,
|
||||||
|
onSave,
|
||||||
|
}: UploadImageDialogProps) {
|
||||||
|
const formSchema = z.object({
|
||||||
|
file: z.instanceof(FileList, { message: "Please select an image file." }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
});
|
||||||
|
const fileRef = form.register("file");
|
||||||
|
|
||||||
|
// upload handler
|
||||||
|
|
||||||
|
const onSubmit = useCallback(
|
||||||
|
(data: z.infer<typeof formSchema>) => {
|
||||||
|
if (!data["file"]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave(data["file"]["0"]);
|
||||||
|
},
|
||||||
|
[onSave],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
{description && <DialogDescription>{description}</DialogDescription>}
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="file"
|
||||||
|
render={() => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="aspect-video h-40 w-full"
|
||||||
|
type="file"
|
||||||
|
{...fileRef}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter className="pt-4">
|
||||||
|
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||||
|
<Button variant="select" type="submit">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
@ -1,20 +1,29 @@
|
|||||||
import { ENV } from "@/env";
|
import { ENV } from "@/env";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { NavData } from "@/types/navigation";
|
import { NavData } from "@/types/navigation";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { isDesktop } from "react-device-detect";
|
||||||
import { FaCompactDisc, FaVideo } from "react-icons/fa";
|
import { FaCompactDisc, FaVideo } from "react-icons/fa";
|
||||||
import { IoSearch } from "react-icons/io5";
|
import { IoSearch } from "react-icons/io5";
|
||||||
import { LuConstruction } from "react-icons/lu";
|
import { LuConstruction } from "react-icons/lu";
|
||||||
import { MdVideoLibrary } from "react-icons/md";
|
import { MdVideoLibrary } from "react-icons/md";
|
||||||
|
import { TbFaceId } from "react-icons/tb";
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
export const ID_LIVE = 1;
|
export const ID_LIVE = 1;
|
||||||
export const ID_REVIEW = 2;
|
export const ID_REVIEW = 2;
|
||||||
export const ID_EXPLORE = 3;
|
export const ID_EXPLORE = 3;
|
||||||
export const ID_EXPORT = 4;
|
export const ID_EXPORT = 4;
|
||||||
export const ID_PLAYGROUND = 5;
|
export const ID_PLAYGROUND = 5;
|
||||||
|
export const ID_FACE_LIBRARY = 6;
|
||||||
|
|
||||||
export default function useNavigation(
|
export default function useNavigation(
|
||||||
variant: "primary" | "secondary" = "primary",
|
variant: "primary" | "secondary" = "primary",
|
||||||
) {
|
) {
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() =>
|
() =>
|
||||||
[
|
[
|
||||||
@ -54,7 +63,15 @@ export default function useNavigation(
|
|||||||
url: "/playground",
|
url: "/playground",
|
||||||
enabled: ENV !== "production",
|
enabled: ENV !== "production",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: ID_FACE_LIBRARY,
|
||||||
|
variant,
|
||||||
|
icon: TbFaceId,
|
||||||
|
title: "Face Library",
|
||||||
|
url: "/faces",
|
||||||
|
enabled: isDesktop && config?.face_recognition.enabled,
|
||||||
|
},
|
||||||
] as NavData[],
|
] as NavData[],
|
||||||
[variant],
|
[config?.face_recognition.enabled, variant],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
170
web/src/pages/FaceLibrary.tsx
Normal file
170
web/src/pages/FaceLibrary.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
|
import Chip from "@/components/indicators/Chip";
|
||||||
|
import UploadImageDialog from "@/components/overlay/dialog/UploadImageDialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
|
import useOptimisticState from "@/hooks/use-optimistic-state";
|
||||||
|
import axios from "axios";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { isDesktop } from "react-device-detect";
|
||||||
|
import { LuImagePlus, LuTrash } from "react-icons/lu";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
|
export default function FaceLibrary() {
|
||||||
|
const [page, setPage] = useState<string>();
|
||||||
|
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
|
||||||
|
const tabsRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// face data
|
||||||
|
|
||||||
|
const { data: faceData } = useSWR("faces");
|
||||||
|
|
||||||
|
const faces = useMemo<string[]>(
|
||||||
|
() => (faceData ? Object.keys(faceData) : []),
|
||||||
|
[faceData],
|
||||||
|
);
|
||||||
|
const faceImages = useMemo<string[]>(
|
||||||
|
() => (pageToggle && faceData ? faceData[pageToggle] : []),
|
||||||
|
[pageToggle, faceData],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pageToggle && faces) {
|
||||||
|
setPageToggle(faces[0]);
|
||||||
|
}
|
||||||
|
// we need to listen on the value of the faces list
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [faces]);
|
||||||
|
|
||||||
|
// upload
|
||||||
|
|
||||||
|
const [upload, setUpload] = useState(false);
|
||||||
|
|
||||||
|
const onUploadImage = useCallback(
|
||||||
|
(file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
axios.post(`faces/${pageToggle}`, formData, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[pageToggle],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex size-full flex-col p-2">
|
||||||
|
<Toaster />
|
||||||
|
|
||||||
|
<UploadImageDialog
|
||||||
|
open={upload}
|
||||||
|
title="Upload Face Image"
|
||||||
|
description={`Upload an image to scan for faces and include for ${pageToggle}`}
|
||||||
|
setOpen={setUpload}
|
||||||
|
onSave={onUploadImage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative flex h-11 w-full items-center justify-between">
|
||||||
|
<ScrollArea className="w-full whitespace-nowrap">
|
||||||
|
<div ref={tabsRef} className="flex flex-row">
|
||||||
|
<ToggleGroup
|
||||||
|
className="*:rounded-md *:px-3 *:py-4"
|
||||||
|
type="single"
|
||||||
|
size="sm"
|
||||||
|
value={pageToggle}
|
||||||
|
onValueChange={(value: string) => {
|
||||||
|
if (value) {
|
||||||
|
setPageToggle(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.values(faces).map((item) => (
|
||||||
|
<ToggleGroupItem
|
||||||
|
key={item}
|
||||||
|
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "UI settings" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||||
|
value={item}
|
||||||
|
data-nav-item={item}
|
||||||
|
aria-label={`Select ${item}`}
|
||||||
|
>
|
||||||
|
<div className="capitalize">{item}</div>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
<ScrollBar orientation="horizontal" className="h-0" />
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
{pageToggle && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{faceImages.map((image: string) => (
|
||||||
|
<FaceImage key={image} name={pageToggle} image={image} />
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
key="upload"
|
||||||
|
className="size-40"
|
||||||
|
onClick={() => setUpload(true)}
|
||||||
|
>
|
||||||
|
<LuImagePlus className="size-10" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FaceImageProps = {
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
};
|
||||||
|
function FaceImage({ name, image }: FaceImageProps) {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
const onDelete = useCallback(() => {
|
||||||
|
axios
|
||||||
|
.post(`/faces/${name}/delete`, { ids: [image] })
|
||||||
|
.then((resp) => {
|
||||||
|
if (resp.status == 200) {
|
||||||
|
toast.error(`Successfully deleted face.`, { position: "top-center" });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (error.response?.data?.message) {
|
||||||
|
toast.error(`Failed to delete: ${error.response.data.message}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error(`Failed to delete: ${error.message}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [name, image]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative h-40"
|
||||||
|
onMouseEnter={isDesktop ? () => setHovered(true) : undefined}
|
||||||
|
onMouseLeave={isDesktop ? () => setHovered(false) : undefined}
|
||||||
|
onClick={isDesktop ? undefined : () => setHovered(!hovered)}
|
||||||
|
>
|
||||||
|
{hovered && (
|
||||||
|
<div className="absolute right-1 top-1">
|
||||||
|
<Chip
|
||||||
|
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
|
||||||
|
onClick={() => onDelete()}
|
||||||
|
>
|
||||||
|
<LuTrash className="size-4 fill-destructive text-destructive" />
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<img
|
||||||
|
className="h-40 rounded-md"
|
||||||
|
src={`${baseUrl}clips/faces/${name}/${image}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -288,6 +288,10 @@ export interface FrigateConfig {
|
|||||||
|
|
||||||
environment_vars: Record<string, unknown>;
|
environment_vars: Record<string, unknown>;
|
||||||
|
|
||||||
|
face_recognition: {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
global_args: string[];
|
global_args: string[];
|
||||||
hwaccel_args: string;
|
hwaccel_args: string;
|
||||||
|
Loading…
Reference in New Issue
Block a user