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({ Details )} - {t("trainFaceAs")} + + {dialogLabel ?? t("trainFaceAs")} +
- {faceNames.sort().map((faceName) => ( + {filteredNames.sort().map((faceName) => ( - {t("trainFace")} + {tooltipLabel ?? t("trainFace")}
); diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx index 666057110..ee9213626 100644 --- a/web/src/pages/FaceLibrary.tsx +++ b/web/src/pages/FaceLibrary.tsx @@ -266,6 +266,34 @@ export default function FaceLibrary() { [setPageToggle, refreshFaces, t], ); + const onReclassify = useCallback( + (image: string, newName: string) => { + axios + .post(`/faces/${pageToggle}/reclassify`, { + id: image, + new_name: newName, + }) + .then((resp) => { + if (resp.status == 200) { + toast.success(t("toast.success.reclassifiedFace"), { + position: "top-center", + }); + refreshFaces(); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(t("toast.error.reclassifyFailed", { errorMessage }), { + position: "top-center", + }); + }); + }, + [pageToggle, refreshFaces, t], + ); + // keyboard const contentRef = useRef(null); @@ -452,10 +480,12 @@ export default function FaceLibrary() { )) )} @@ -601,11 +631,11 @@ function LibrarySelector({ className="group flex items-center justify-between p-0" >
setPageToggle(face)} > {face} - + ({faceData?.[face].length})
@@ -983,18 +1013,22 @@ function FaceAttemptGroup({ type FaceGridProps = { contentRef: MutableRefObject; faceImages: string[]; + faceNames: string[]; pageToggle: string; selectedFaces: string[]; onClickFaces: (images: string[], ctrl: boolean) => void; onDelete: (name: string, ids: string[]) => void; + onReclassify: (image: string, newName: string) => void; }; function FaceGrid({ contentRef, faceImages, + faceNames, pageToggle, selectedFaces, onClickFaces, onDelete, + onReclassify, }: FaceGridProps) { const { t } = useTranslation(["views/faceLibrary"]); @@ -1032,6 +1066,17 @@ function FaceGrid({ i18nLibrary="views/faceLibrary" onClick={(data, meta) => onClickFaces([data.filename], meta)} > + onReclassify(image, newName)} + > + + + + { e.stopPropagation(); onDelete([image]);