From d11f46bbce19701d478e2dd4f89fe77ba58a2237 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 23 Apr 2025 18:27:46 -0500 Subject: [PATCH] Add ability to rename faces in the Face Library (#17879) * api endpoint * embeddings rename function * frontend and i18n keys * lazy load train tab images * only log exception to make codeql happy --- frigate/api/classification.py | 30 ++++++ .../api/defs/request/classification_body.py | 5 + frigate/embeddings/__init__.py | 38 +++++++ web/public/locales/en/views/faceLibrary.json | 8 ++ web/src/pages/FaceLibrary.tsx | 101 ++++++++++++++++-- 5 files changed, 171 insertions(+), 11 deletions(-) create mode 100644 frigate/api/defs/request/classification_body.py diff --git a/frigate/api/classification.py b/frigate/api/classification.py index 1f6d8b792..8f0fb6462 100644 --- a/frigate/api/classification.py +++ b/frigate/api/classification.py @@ -14,6 +14,7 @@ from peewee import DoesNotExist from playhouse.shortcuts import model_to_dict from frigate.api.auth import require_role +from frigate.api.defs.request.classification_body import RenameFaceBody from frigate.api.defs.tags import Tags from frigate.config.camera import DetectConfig from frigate.const import FACE_DIR @@ -260,6 +261,35 @@ def deregister_faces(request: Request, name: str, body: dict = None): ) +@router.put("/faces/{old_name}/rename", dependencies=[Depends(require_role(["admin"]))]) +def rename_face(request: Request, old_name: str, body: RenameFaceBody): + if not request.app.frigate_config.face_recognition.enabled: + return JSONResponse( + status_code=400, + content={"message": "Face recognition is not enabled.", "success": False}, + ) + + context: EmbeddingsContext = request.app.embeddings + try: + context.rename_face(old_name, body.new_name) + return JSONResponse( + content={ + "success": True, + "message": f"Successfully renamed face to {body.new_name}.", + }, + status_code=200, + ) + except ValueError as e: + logger.error(e) + return JSONResponse( + status_code=400, + content={ + "message": "Error renaming face. Check Frigate logs.", + "success": False, + }, + ) + + @router.put("/lpr/reprocess") def reprocess_license_plate(request: Request, event_id: str): if not request.app.frigate_config.lpr.enabled: diff --git a/frigate/api/defs/request/classification_body.py b/frigate/api/defs/request/classification_body.py new file mode 100644 index 000000000..c4a32c332 --- /dev/null +++ b/frigate/api/defs/request/classification_body.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class RenameFaceBody(BaseModel): + new_name: str diff --git a/frigate/embeddings/__init__.py b/frigate/embeddings/__init__.py index c60465845..5e713b3c1 100644 --- a/frigate/embeddings/__init__.py +++ b/frigate/embeddings/__init__.py @@ -5,11 +5,13 @@ import json import logging import multiprocessing as mp import os +import re import signal import threading from types import FrameType from typing import Optional, Union +from pathvalidate import ValidationError, sanitize_filename from setproctitle import setproctitle from frigate.comms.embeddings_updater import EmbeddingsRequestEnum, EmbeddingsRequestor @@ -240,6 +242,42 @@ class EmbeddingsContext: EmbeddingsRequestEnum.clear_face_classifier.value, None ) + def rename_face(self, old_name: str, new_name: str) -> None: + valid_name_pattern = r"^[a-zA-Z0-9_-]{1,50}$" + + try: + sanitized_old_name = sanitize_filename(old_name, replacement_text="_") + sanitized_new_name = sanitize_filename(new_name, replacement_text="_") + except ValidationError as e: + raise ValueError(f"Invalid face name: {str(e)}") + + if not re.match(valid_name_pattern, old_name): + raise ValueError(f"Invalid old face name: {old_name}") + if not re.match(valid_name_pattern, new_name): + raise ValueError(f"Invalid new face name: {new_name}") + if sanitized_old_name != old_name: + raise ValueError(f"Old face name contains invalid characters: {old_name}") + if sanitized_new_name != new_name: + raise ValueError(f"New face name contains invalid characters: {new_name}") + + old_path = os.path.normpath(os.path.join(FACE_DIR, old_name)) + new_path = os.path.normpath(os.path.join(FACE_DIR, new_name)) + + # Prevent path traversal + if not old_path.startswith( + os.path.normpath(FACE_DIR) + ) or not new_path.startswith(os.path.normpath(FACE_DIR)): + raise ValueError("Invalid path detected") + + if not os.path.exists(old_path): + raise ValueError(f"Face {old_name} not found.") + + os.rename(old_path, new_path) + + self.requestor.send_data( + EmbeddingsRequestEnum.clear_face_classifier.value, None + ) + def update_description(self, event_id: str, description: str) -> None: self.requestor.send_data( EmbeddingsRequestEnum.embed_description.value, diff --git a/web/public/locales/en/views/faceLibrary.json b/web/public/locales/en/views/faceLibrary.json index da6c44ff3..ee1cd3425 100644 --- a/web/public/locales/en/views/faceLibrary.json +++ b/web/public/locales/en/views/faceLibrary.json @@ -36,9 +36,15 @@ "title": "Delete Name", "desc": "Are you sure you want to delete the collection {{name}}? This will permanently delete all associated faces." }, + "renameFace": { + "title": "Rename Face", + "desc": "Enter a new name for {{name}}" + }, "button": { "deleteFaceAttempts": "Delete Face Attempts", "addFace": "Add Face", + "renameFace": "Rename Face", + "deleteFace": "Delete Face", "uploadImage": "Upload Image", "reprocessFace": "Reprocess Face" }, @@ -62,6 +68,7 @@ "deletedName_zero": "Empty collection deleted successfully.", "deletedName_one": "{{count}} face has been successfully deleted.", "deletedName_other": "{{count}} faces have been successfully deleted.", + "renamedFace": "Successfully renamed face to {{name}}", "trainedFace": "Successfully trained face.", "updatedFaceScore": "Successfully updated face score." }, @@ -70,6 +77,7 @@ "addFaceLibraryFailed": "Failed to set face name: {{errorMessage}}", "deleteFaceFailed": "Failed to delete: {{errorMessage}}", "deleteNameFailed": "Failed to delete name: {{errorMessage}}", + "renameFaceFailed": "Failed to rename face: {{errorMessage}}", "trainFailed": "Failed to train: {{errorMessage}}", "updateFaceScoreFailed": "Failed to update face score: {{errorMessage}}" } diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx index 4b5b7ae9a..ef06764ef 100644 --- a/web/src/pages/FaceLibrary.tsx +++ b/web/src/pages/FaceLibrary.tsx @@ -3,6 +3,7 @@ import TimeAgo from "@/components/dynamic/TimeAgo"; import AddFaceIcon from "@/components/icons/AddFaceIcon"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import CreateFaceWizardDialog from "@/components/overlay/detail/FaceCreateWizardDialog"; +import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog"; import UploadImageDialog from "@/components/overlay/dialog/UploadImageDialog"; import FaceSelectionDialog from "@/components/overlay/FaceSelectionDialog"; import { Button } from "@/components/ui/button"; @@ -41,6 +42,7 @@ import { isDesktop, isMobile } from "react-device-detect"; import { useTranslation } from "react-i18next"; import { LuImagePlus, + LuPencil, LuRefreshCw, LuScanFace, LuSearch, @@ -221,6 +223,32 @@ export default function FaceLibrary() { [faceImages, refreshFaces, setPageToggle, t], ); + const onRename = useCallback( + (oldName: string, newName: string) => { + axios + .put(`/faces/${oldName}/rename`, { new_name: newName }) + .then((resp) => { + if (resp.status === 200) { + toast.success(t("toast.success.renamedFace", { name: newName }), { + position: "top-center", + }); + setPageToggle("train"); + refreshFaces(); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(t("toast.error.renameFaceFailed", { errorMessage }), { + position: "top-center", + }); + }); + }, + [setPageToggle, refreshFaces, t], + ); + // keyboard useKeyboardListener(["a", "Escape"], (key, modifiers) => { @@ -274,6 +302,7 @@ export default function FaceLibrary() { trainImages={trainImages} setPageToggle={setPageToggle} onDelete={onDelete} + onRename={onRename} /> {selectedFaces?.length > 0 ? (