add ability to reclassify faces

This commit is contained in:
Josh Hawkins 2026-03-23 18:26:48 -05:00
parent 1ca138ff55
commit b241dc20ec
5 changed files with 145 additions and 6 deletions

View File

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

View File

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

View File

@ -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({
<DrawerDescription>Details</DrawerDescription>
</DrawerHeader>
)}
<DropdownMenuLabel>{t("trainFaceAs")}</DropdownMenuLabel>
<DropdownMenuLabel>
{dialogLabel ?? t("trainFaceAs")}
</DropdownMenuLabel>
<div
className={cn(
"flex max-h-[40dvh] flex-col overflow-y-auto overflow-x-hidden",
isMobile && "gap-2 pb-4",
)}
>
{faceNames.sort().map((faceName) => (
{filteredNames.sort().map((faceName) => (
<SelectorItem
key={faceName}
className="flex cursor-pointer gap-2 smart-capitalize"
@ -112,7 +126,7 @@ export default function FaceSelectionDialog({
</div>
</SelectorContent>
</Selector>
<TooltipContent>{t("trainFace")}</TooltipContent>
<TooltipContent>{tooltipLabel ?? t("trainFace")}</TooltipContent>
</Tooltip>
</div>
);

View File

@ -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<HTMLDivElement | null>(null);
@ -452,10 +480,12 @@ export default function FaceLibrary() {
<FaceGrid
contentRef={contentRef}
faceImages={faceImages}
faceNames={faces}
pageToggle={pageToggle}
selectedFaces={selectedFaces}
onClickFaces={onClickFaces}
onDelete={onDelete}
onReclassify={onReclassify}
/>
))
)}
@ -601,11 +631,11 @@ function LibrarySelector({
className="group flex items-center justify-between p-0"
>
<div
className="flex-grow cursor-pointer"
className="flex-grow cursor-pointer px-2 py-1.5"
onClick={() => setPageToggle(face)}
>
{face}
<span className="ml-2 px-2 py-1.5 text-muted-foreground">
<span className="ml-2 text-muted-foreground">
({faceData?.[face].length})
</span>
</div>
@ -983,18 +1013,22 @@ function FaceAttemptGroup({
type FaceGridProps = {
contentRef: MutableRefObject<HTMLDivElement | null>;
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)}
>
<FaceSelectionDialog
faceNames={faceNames}
excludeName={pageToggle}
dialogLabel={t("reclassifyFaceAs")}
tooltipLabel={t("reclassifyFace")}
onTrainAttempt={(newName) => onReclassify(image, newName)}
>
<BlurredIconButton>
<AddFaceIcon className="size-5" />
</BlurredIconButton>
</FaceSelectionDialog>
<Tooltip>
<TooltipTrigger>
<LuTrash2

View File

@ -869,7 +869,7 @@ function DatasetGrid({
<Tooltip>
<TooltipTrigger>
<LuTrash2
className="size-5 cursor-pointer text-primary-variant hover:text-danger"
className="size-5 cursor-pointer text-gray-200 hover:text-danger"
onClick={(e) => {
e.stopPropagation();
onDelete([image]);