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:
Nicolas Mowen 2024-11-26 13:41:49 -07:00 committed by Blake Blackshear
parent dd7b1be7f4
commit 0e4ff91d6b
15 changed files with 397 additions and 137 deletions

View File

@ -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

View File

@ -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'

View File

@ -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.

View File

@ -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,

View File

@ -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."

View File

@ -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
);
""")

View File

@ -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(

View File

@ -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

View 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

View File

@ -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),
)

View File

@ -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>

View 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>
);
}

View File

@ -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],
); );
} }

View 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>
);
}

View File

@ -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;