From b241dc20ec0f96580338a9371a516e13a29f5317 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 23 Mar 2026 18:26:48 -0500
Subject: [PATCH] add ability to reclassify faces
---
frigate/api/classification.py | 76 +++++++++++++++++++
web/public/locales/en/views/faceLibrary.json | 4 +
.../overlay/FaceSelectionDialog.tsx | 20 ++++-
web/src/pages/FaceLibrary.tsx | 49 +++++++++++-
.../classification/ModelTrainingView.tsx | 2 +-
5 files changed, 145 insertions(+), 6 deletions(-)
diff --git a/frigate/api/classification.py b/frigate/api/classification.py
index 6b11d3903..dea4d28b2 100644
--- a/frigate/api/classification.py
+++ b/frigate/api/classification.py
@@ -338,6 +338,82 @@ async def recognize_face(request: Request, file: UploadFile):
)
+@router.post(
+ "/faces/{name}/reclassify",
+ response_model=GenericResponse,
+ dependencies=[Depends(require_role(["admin"]))],
+ summary="Reclassify a face image to a different name",
+ description="""Moves a single face image from one person's folder to another.
+ The image is moved and renamed, and the face classifier is cleared to
+ incorporate the change. Returns a success message or an error if the
+ image or target name is invalid.""",
+)
+def reclassify_face_image(request: Request, name: str, body: dict = None):
+ if not request.app.frigate_config.face_recognition.enabled:
+ return JSONResponse(
+ status_code=400,
+ content={"message": "Face recognition is not enabled.", "success": False},
+ )
+
+ json: dict[str, Any] = body or {}
+ image_id = sanitize_filename(json.get("id", ""))
+ new_name = sanitize_filename(json.get("new_name", ""))
+
+ if not image_id or not new_name:
+ return JSONResponse(
+ content=(
+ {
+ "success": False,
+ "message": "Both 'id' and 'new_name' are required.",
+ }
+ ),
+ status_code=400,
+ )
+
+ if new_name == name:
+ return JSONResponse(
+ content=(
+ {
+ "success": False,
+ "message": "New name must differ from the current name.",
+ }
+ ),
+ status_code=400,
+ )
+
+ source_folder = os.path.join(FACE_DIR, sanitize_filename(name))
+ source_file = os.path.join(source_folder, image_id)
+
+ if not os.path.isfile(source_file):
+ return JSONResponse(
+ content=(
+ {
+ "success": False,
+ "message": f"Image not found: {image_id}",
+ }
+ ),
+ status_code=404,
+ )
+
+ target_filename = f"{new_name}-{datetime.datetime.now().timestamp()}.webp"
+ target_folder = os.path.join(FACE_DIR, new_name)
+
+ os.makedirs(target_folder, exist_ok=True)
+ shutil.move(source_file, os.path.join(target_folder, target_filename))
+
+ # Clean up empty source folder
+ if os.path.exists(source_folder) and not os.listdir(source_folder):
+ os.rmdir(source_folder)
+
+ context: EmbeddingsContext = request.app.embeddings
+ context.clear_face_classifier()
+
+ return JSONResponse(
+ content=({"success": True, "message": "Successfully reclassified face."}),
+ status_code=200,
+ )
+
+
@router.post(
"/faces/{name}/delete",
response_model=GenericResponse,
diff --git a/web/public/locales/en/views/faceLibrary.json b/web/public/locales/en/views/faceLibrary.json
index 354049156..4f8a9cf2d 100644
--- a/web/public/locales/en/views/faceLibrary.json
+++ b/web/public/locales/en/views/faceLibrary.json
@@ -66,6 +66,8 @@
"nofaces": "No faces available",
"trainFaceAs": "Train Face as:",
"trainFace": "Train Face",
+ "reclassifyFaceAs": "Reclassify Face as:",
+ "reclassifyFace": "Reclassify Face",
"toast": {
"success": {
"uploadedImage": "Successfully uploaded image.",
@@ -77,6 +79,7 @@
"deletedName_other": "{{count}} faces have been successfully deleted.",
"renamedFace": "Successfully renamed face to {{name}}",
"trainedFace": "Successfully trained face.",
+ "reclassifiedFace": "Successfully reclassified face.",
"updatedFaceScore": "Successfully updated face score to {{name}} ({{score}})."
},
"error": {
@@ -86,6 +89,7 @@
"deleteNameFailed": "Failed to delete name: {{errorMessage}}",
"renameFaceFailed": "Failed to rename face: {{errorMessage}}",
"trainFailed": "Failed to train: {{errorMessage}}",
+ "reclassifyFailed": "Failed to reclassify face: {{errorMessage}}",
"updateFaceScoreFailed": "Failed to update face score: {{errorMessage}}"
}
}
diff --git a/web/src/components/overlay/FaceSelectionDialog.tsx b/web/src/components/overlay/FaceSelectionDialog.tsx
index 78cf7ce12..aa816023c 100644
--- a/web/src/components/overlay/FaceSelectionDialog.tsx
+++ b/web/src/components/overlay/FaceSelectionDialog.tsx
@@ -30,17 +30,29 @@ import { Button } from "../ui/button";
type FaceSelectionDialogProps = {
className?: string;
faceNames: string[];
+ excludeName?: string;
+ dialogLabel?: string;
+ tooltipLabel?: string;
onTrainAttempt: (name: string) => void;
children: ReactNode;
};
export default function FaceSelectionDialog({
className,
faceNames,
+ excludeName,
+ dialogLabel,
+ tooltipLabel,
onTrainAttempt,
children,
}: FaceSelectionDialogProps) {
const { t } = useTranslation(["views/faceLibrary"]);
+ const filteredNames = useMemo(
+ () =>
+ excludeName ? faceNames.filter((n) => n !== excludeName) : faceNames,
+ [faceNames, excludeName],
+ );
+
const isChildButton = useMemo(
() => React.isValidElement(children) && children.type === Button,
[children],
@@ -86,14 +98,16 @@ export default function FaceSelectionDialog({