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
This commit is contained in:
Josh Hawkins 2025-04-23 18:27:46 -05:00 committed by GitHub
parent aa7899e9dc
commit d11f46bbce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 171 additions and 11 deletions

View File

@ -14,6 +14,7 @@ from peewee import DoesNotExist
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
from frigate.api.auth import require_role 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.api.defs.tags import Tags
from frigate.config.camera import DetectConfig from frigate.config.camera import DetectConfig
from frigate.const import FACE_DIR 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") @router.put("/lpr/reprocess")
def reprocess_license_plate(request: Request, event_id: str): def reprocess_license_plate(request: Request, event_id: str):
if not request.app.frigate_config.lpr.enabled: if not request.app.frigate_config.lpr.enabled:

View File

@ -0,0 +1,5 @@
from pydantic import BaseModel
class RenameFaceBody(BaseModel):
new_name: str

View File

@ -5,11 +5,13 @@ import json
import logging import logging
import multiprocessing as mp import multiprocessing as mp
import os import os
import re
import signal import signal
import threading import threading
from types import FrameType from types import FrameType
from typing import Optional, Union from typing import Optional, Union
from pathvalidate import ValidationError, sanitize_filename
from setproctitle import setproctitle from setproctitle import setproctitle
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum, EmbeddingsRequestor from frigate.comms.embeddings_updater import EmbeddingsRequestEnum, EmbeddingsRequestor
@ -240,6 +242,42 @@ class EmbeddingsContext:
EmbeddingsRequestEnum.clear_face_classifier.value, None 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: def update_description(self, event_id: str, description: str) -> None:
self.requestor.send_data( self.requestor.send_data(
EmbeddingsRequestEnum.embed_description.value, EmbeddingsRequestEnum.embed_description.value,

View File

@ -36,9 +36,15 @@
"title": "Delete Name", "title": "Delete Name",
"desc": "Are you sure you want to delete the collection {{name}}? This will permanently delete all associated faces." "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": { "button": {
"deleteFaceAttempts": "Delete Face Attempts", "deleteFaceAttempts": "Delete Face Attempts",
"addFace": "Add Face", "addFace": "Add Face",
"renameFace": "Rename Face",
"deleteFace": "Delete Face",
"uploadImage": "Upload Image", "uploadImage": "Upload Image",
"reprocessFace": "Reprocess Face" "reprocessFace": "Reprocess Face"
}, },
@ -62,6 +68,7 @@
"deletedName_zero": "Empty collection deleted successfully.", "deletedName_zero": "Empty collection deleted successfully.",
"deletedName_one": "{{count}} face has been successfully deleted.", "deletedName_one": "{{count}} face has been successfully deleted.",
"deletedName_other": "{{count}} faces have been successfully deleted.", "deletedName_other": "{{count}} faces have been successfully deleted.",
"renamedFace": "Successfully renamed face to {{name}}",
"trainedFace": "Successfully trained face.", "trainedFace": "Successfully trained face.",
"updatedFaceScore": "Successfully updated face score." "updatedFaceScore": "Successfully updated face score."
}, },
@ -70,6 +77,7 @@
"addFaceLibraryFailed": "Failed to set face name: {{errorMessage}}", "addFaceLibraryFailed": "Failed to set face name: {{errorMessage}}",
"deleteFaceFailed": "Failed to delete: {{errorMessage}}", "deleteFaceFailed": "Failed to delete: {{errorMessage}}",
"deleteNameFailed": "Failed to delete name: {{errorMessage}}", "deleteNameFailed": "Failed to delete name: {{errorMessage}}",
"renameFaceFailed": "Failed to rename face: {{errorMessage}}",
"trainFailed": "Failed to train: {{errorMessage}}", "trainFailed": "Failed to train: {{errorMessage}}",
"updateFaceScoreFailed": "Failed to update face score: {{errorMessage}}" "updateFaceScoreFailed": "Failed to update face score: {{errorMessage}}"
} }

View File

@ -3,6 +3,7 @@ import TimeAgo from "@/components/dynamic/TimeAgo";
import AddFaceIcon from "@/components/icons/AddFaceIcon"; import AddFaceIcon from "@/components/icons/AddFaceIcon";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import CreateFaceWizardDialog from "@/components/overlay/detail/FaceCreateWizardDialog"; import CreateFaceWizardDialog from "@/components/overlay/detail/FaceCreateWizardDialog";
import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
import UploadImageDialog from "@/components/overlay/dialog/UploadImageDialog"; import UploadImageDialog from "@/components/overlay/dialog/UploadImageDialog";
import FaceSelectionDialog from "@/components/overlay/FaceSelectionDialog"; import FaceSelectionDialog from "@/components/overlay/FaceSelectionDialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -41,6 +42,7 @@ import { isDesktop, isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
LuImagePlus, LuImagePlus,
LuPencil,
LuRefreshCw, LuRefreshCw,
LuScanFace, LuScanFace,
LuSearch, LuSearch,
@ -221,6 +223,32 @@ export default function FaceLibrary() {
[faceImages, refreshFaces, setPageToggle, t], [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 // keyboard
useKeyboardListener(["a", "Escape"], (key, modifiers) => { useKeyboardListener(["a", "Escape"], (key, modifiers) => {
@ -274,6 +302,7 @@ export default function FaceLibrary() {
trainImages={trainImages} trainImages={trainImages}
setPageToggle={setPageToggle} setPageToggle={setPageToggle}
onDelete={onDelete} onDelete={onDelete}
onRename={onRename}
/> />
{selectedFaces?.length > 0 ? ( {selectedFaces?.length > 0 ? (
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
@ -338,6 +367,7 @@ type LibrarySelectorProps = {
trainImages: string[]; trainImages: string[];
setPageToggle: (toggle: string | undefined) => void; setPageToggle: (toggle: string | undefined) => void;
onDelete: (name: string, ids: string[], isName: boolean) => void; onDelete: (name: string, ids: string[], isName: boolean) => void;
onRename: (old_name: string, new_name: string) => void;
}; };
function LibrarySelector({ function LibrarySelector({
pageToggle, pageToggle,
@ -346,9 +376,11 @@ function LibrarySelector({
trainImages, trainImages,
setPageToggle, setPageToggle,
onDelete, onDelete,
onRename,
}: LibrarySelectorProps) { }: LibrarySelectorProps) {
const { t } = useTranslation(["views/faceLibrary"]); const { t } = useTranslation(["views/faceLibrary"]);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null); const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [renameFace, setRenameFace] = useState<string | null>(null);
const handleDeleteFace = useCallback( const handleDeleteFace = useCallback(
(faceName: string) => { (faceName: string) => {
@ -361,6 +393,13 @@ function LibrarySelector({
[faceData, onDelete, setPageToggle], [faceData, onDelete, setPageToggle],
); );
const handleSetOpen = useCallback(
(open: boolean) => {
setRenameFace(open ? renameFace : null);
},
[renameFace],
);
return ( return (
<> <>
<Dialog <Dialog
@ -393,6 +432,18 @@ function LibrarySelector({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<TextEntryDialog
open={!!renameFace}
setOpen={handleSetOpen}
title={t("renameFace.title")}
description={t("renameFace.desc", { name: renameFace })}
onSave={(newName) => {
onRename(renameFace!, newName);
setRenameFace(null);
}}
defaultValue={renameFace || ""}
/>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button className="flex justify-between smart-capitalize"> <Button className="flex justify-between smart-capitalize">
@ -440,17 +491,44 @@ function LibrarySelector({
({faceData?.[face].length}) ({faceData?.[face].length})
</span> </span>
</div> </div>
<Button <div className="flex gap-0.5">
variant="ghost" <Tooltip>
size="icon" <TooltipTrigger asChild>
className="size-7 opacity-0 transition-opacity group-hover:opacity-100" <Button
onClick={(e) => { variant="ghost"
e.stopPropagation(); size="icon"
setConfirmDelete(face); className="size-7 opacity-0 transition-opacity group-hover:opacity-100"
}} onClick={(e) => {
> e.stopPropagation();
<LuTrash2 className="size-4 text-destructive" /> setRenameFace(face);
</Button> }}
>
<LuPencil className="size-4 text-primary" />
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{t("button.renameFace")}</TooltipContent>
</TooltipPortal>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(face);
}}
>
<LuTrash2 className="size-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{t("button.deleteFace")}</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
@ -584,6 +662,7 @@ function TrainingGrid({
</div> </div>
<img <img
className="w-full" className="w-full"
loading="lazy"
src={`${baseUrl}api/events/${selectedEvent?.id}/${selectedEvent?.has_snapshot ? "snapshot.jpg" : "thumbnail.jpg"}`} src={`${baseUrl}api/events/${selectedEvent?.id}/${selectedEvent?.has_snapshot ? "snapshot.jpg" : "thumbnail.jpg"}`}
/> />
</DialogContent> </DialogContent>