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, buttonVariants } from "@/components/ui/button"; import BlurredIconButton from "@/components/button/BlurredIconButton"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { Toaster } from "@/components/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useOptimisticState from "@/hooks/use-optimistic-state"; import { cn } from "@/lib/utils"; import { Event } from "@/types/event"; import { FaceLibraryData } from "@/types/face"; import { FrigateConfig } from "@/types/frigateConfig"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import axios from "axios"; import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { isDesktop } from "react-device-detect"; import { Trans, useTranslation } from "react-i18next"; import { LuFolderCheck, LuImagePlus, LuPencil, LuRefreshCw, LuScanFace, LuTrash2, } from "react-icons/lu"; import { toast } from "sonner"; import useSWR from "swr"; import { ClassificationCard, GroupedClassificationCard, } from "@/components/card/ClassificationCard"; import { ClassificationItemData } from "@/types/classification"; export default function FaceLibrary() { const { t } = useTranslation(["views/faceLibrary"]); const { data: config } = useSWR("config"); // title useEffect(() => { document.title = t("documentTitle"); }, [t]); const [page, setPage] = useState("train"); const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); // face data const { data: faceData, mutate: refreshFaces } = useSWR("faces"); const faces = useMemo( () => faceData ? Object.keys(faceData) .filter((face) => face != "train") .sort() : [], [faceData], ); const faceImages = useMemo( () => (pageToggle && faceData ? faceData[pageToggle] : []), [pageToggle, faceData], ); const trainImages = useMemo( () => faceData?.["train"] || [], [faceData], ); // upload const [upload, setUpload] = useState(false); const [addFace, setAddFace] = useState(false); // input focus for keyboard shortcuts const onUploadImage = useCallback( (file: File) => { const formData = new FormData(); formData.append("file", file); axios .post(`faces/${pageToggle}/register`, formData, { headers: { "Content-Type": "multipart/form-data", }, }) .then((resp) => { if (resp.status == 200) { setUpload(false); refreshFaces(); toast.success(t("toast.success.uploadedImage"), { position: "top-center", }); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error(t("toast.error.uploadingImageFailed", { errorMessage }), { position: "top-center", }); }); }, [pageToggle, refreshFaces, t], ); // face multiselect const [selectedFaces, setSelectedFaces] = useState([]); const onClickFaces = useCallback( (images: string[], ctrl: boolean) => { if (selectedFaces.length == 0 && !ctrl) { return; } let newSelectedFaces = [...selectedFaces]; images.forEach((imageId) => { const index = newSelectedFaces.indexOf(imageId); if (index != -1) { if (selectedFaces.length == 1) { newSelectedFaces = []; } else { const copy = [ ...newSelectedFaces.slice(0, index), ...newSelectedFaces.slice(index + 1), ]; newSelectedFaces = copy; } } else { newSelectedFaces.push(imageId); } }); setSelectedFaces(newSelectedFaces); }, [selectedFaces, setSelectedFaces], ); const [deleteDialogOpen, setDeleteDialogOpen] = useState<{ name: string; ids: string[]; } | null>(null); const onDelete = useCallback( (name: string, ids: string[], isName: boolean = false) => { axios .post(`/faces/${name}/delete`, { ids }) .then((resp) => { setSelectedFaces([]); if (resp.status == 200) { if (isName) { toast.success( t("toast.success.deletedName", { count: ids.length }), { position: "top-center", }, ); } else { toast.success( t("toast.success.deletedFace", { count: ids.length }), { position: "top-center", }, ); } if (faceImages.length == 1) { // face has been deleted setPageToggle("train"); } refreshFaces(); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; if (isName) { toast.error(t("toast.error.deleteNameFailed", { errorMessage }), { position: "top-center", }); } else { toast.error(t("toast.error.deleteFaceFailed", { errorMessage }), { position: "top-center", }); } }); }, [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 const contentRef = useRef(null); useKeyboardListener( ["a", "Escape"], (key, modifiers) => { if (!modifiers.down) { return true; } switch (key) { case "a": if (modifiers.ctrl && !modifiers.repeat) { if (selectedFaces.length) { setSelectedFaces([]); } else { setSelectedFaces([ ...(pageToggle === "train" ? trainImages : faceImages), ]); } return true; } break; case "Escape": setSelectedFaces([]); return true; } return false; }, contentRef, ); useEffect(() => { setSelectedFaces([]); }, [pageToggle]); if (!config) { return ; } return (
setDeleteDialogOpen(null)} > {t("deleteFaceAttempts.title")} deleteFaceAttempts.desc {t("button.cancel", { ns: "common" })} { if (deleteDialogOpen) { onDelete(deleteDialogOpen.name, deleteDialogOpen.ids); setDeleteDialogOpen(null); } }} > {t("button.delete", { ns: "common" })}
{selectedFaces?.length > 0 ? (
{`${selectedFaces.length} selected`}
{"|"}
setSelectedFaces([])} > {t("button.unselect", { ns: "common" })}
) : (
{pageToggle != "train" && ( )}
)}
{pageToggle && faceImages?.length === 0 && pageToggle !== "train" ? (
{t("nofaces")}
) : ( pageToggle && (pageToggle == "train" ? ( ) : ( )) )}
); } type LibrarySelectorProps = { pageToggle: string | undefined; faceData?: FaceLibraryData; faces: string[]; trainImages: string[]; setPageToggle: (toggle: string) => void; onDelete: (name: string, ids: string[], isName: boolean) => void; onRename: (old_name: string, new_name: string) => void; }; function LibrarySelector({ pageToggle, faceData, faces, trainImages, setPageToggle, onDelete, onRename, }: LibrarySelectorProps) { const { t } = useTranslation(["views/faceLibrary"]); const [confirmDelete, setConfirmDelete] = useState(null); const [renameFace, setRenameFace] = useState(null); const handleDeleteFace = useCallback( (faceName: string) => { // Get all image IDs for this face const imageIds = faceData?.[faceName] || []; onDelete(faceName, imageIds, true); setPageToggle("train"); }, [faceData, onDelete, setPageToggle], ); const handleSetOpen = useCallback( (open: boolean) => { setRenameFace(open ? renameFace : null); }, [renameFace], ); return ( <> !open && setConfirmDelete(null)} > {t("deleteFaceLibrary.title")} {t("deleteFaceLibrary.desc", { name: confirmDelete })}
{ onRename(renameFace!, newName); setRenameFace(null); }} defaultValue={renameFace || ""} regexPattern={/^[\p{L}\p{N}\s'_-]{1,50}$/u} regexErrorMessage={t("description.invalidName")} /> setPageToggle("train")} >
{t("train.title")}
({trainImages.length})
{trainImages.length > 0 && faces.length > 0 && ( <>
{t("collections")}
)} {Object.values(faces).map((face) => (
setPageToggle(face)} > {face} ({faceData?.[face].length})
{t("button.renameFace")} {t("button.deleteFace")}
))}
); } type TrainingGridProps = { config: FrigateConfig; contentRef: MutableRefObject; attemptImages: string[]; faceNames: string[]; selectedFaces: string[]; onClickFaces: (images: string[], ctrl: boolean) => void; onRefresh: () => void; }; function TrainingGrid({ config, contentRef, attemptImages, faceNames, selectedFaces, onClickFaces, onRefresh, }: TrainingGridProps) { const { t } = useTranslation(["views/faceLibrary"]); // face data const faceGroups = useMemo(() => { const groups: { [eventId: string]: ClassificationItemData[] } = {}; const faces = attemptImages .map((image) => { const parts = image.split("-"); try { return { filename: image, filepath: `clips/faces/train/${image}`, timestamp: Number.parseFloat(parts[2]), eventId: `${parts[0]}-${parts[1]}`, name: parts[3], score: Number.parseFloat(parts[4]), }; } catch { return null; } }) .filter((v) => v != null); faces .sort((a, b) => a.eventId.localeCompare(b.eventId)) .reverse() .forEach((face) => { if (groups[face.eventId]) { groups[face.eventId].push(face); } else { groups[face.eventId] = [face]; } }); return groups; }, [attemptImages]); const eventIdsQuery = useMemo( () => Object.keys(faceGroups).join(","), [faceGroups], ); const { data: events } = useSWR([ "event_ids", { ids: eventIdsQuery }, ]); if (attemptImages.length == 0) { return (
{t("train.empty")}
); } return (
{Object.entries(faceGroups).map(([key, group]) => { const event = events?.find((ev) => ev.id == key); return (
); })}
); } type FaceAttemptGroupProps = { config: FrigateConfig; group: ClassificationItemData[]; event?: Event; faceNames: string[]; selectedFaces: string[]; onClickFaces: (image: string[], ctrl: boolean) => void; onRefresh: () => void; }; function FaceAttemptGroup({ config, group, event, faceNames, selectedFaces, onClickFaces, onRefresh, }: FaceAttemptGroupProps) { const { t } = useTranslation(["views/faceLibrary", "views/explore"]); // data const threshold = useMemo(() => { return { recognition: config.face_recognition.recognition_threshold, unknown: config.face_recognition.unknown_score, }; }, [config]); // interaction const handleClickEvent = useCallback( (meta: boolean) => { if (!meta) { return; } else { const anySelected = group.find((face) => selectedFaces.includes(face.filename)) != undefined; if (anySelected) { // deselect all const toDeselect: string[] = []; group.forEach((face) => { if (selectedFaces.includes(face.filename)) { toDeselect.push(face.filename); } }); onClickFaces(toDeselect, false); } else { // select all onClickFaces( group.map((face) => face.filename), true, ); } } }, [group, selectedFaces, onClickFaces], ); // api calls const onTrainAttempt = useCallback( (data: ClassificationItemData, trainName: string) => { axios .post(`/faces/train/${trainName}/classify`, { training_file: data.filename, }) .then((resp) => { if (resp.status == 200) { toast.success(t("toast.success.trainedFace"), { position: "top-center", }); onRefresh(); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error(t("toast.error.trainFailed", { errorMessage }), { position: "top-center", }); }); }, [onRefresh, t], ); const onReprocess = useCallback( (data: ClassificationItemData) => { axios .post(`/faces/reprocess`, { training_file: data.filename }) .then((resp) => { if (resp.status == 200) { toast.success(t("toast.success.updatedFaceScore"), { position: "top-center", }); onRefresh(); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("toast.error.updateFaceScoreFailed", { errorMessage }), { position: "top-center", }, ); }); }, [onRefresh, t], ); return ( { if (data) { onClickFaces([data.filename], true); } else { handleClickEvent(true); } }} > {(data) => ( <> onTrainAttempt(data, name)} > onReprocess(data)}> {t("button.reprocessFace")} )} ); } type FaceGridProps = { contentRef: MutableRefObject; faceImages: string[]; pageToggle: string; selectedFaces: string[]; onClickFaces: (images: string[], ctrl: boolean) => void; onDelete: (name: string, ids: string[]) => void; }; function FaceGrid({ contentRef, faceImages, pageToggle, selectedFaces, onClickFaces, onDelete, }: FaceGridProps) { const { t } = useTranslation(["views/faceLibrary"]); const sortedFaces = useMemo( () => (faceImages || []).sort().reverse(), [faceImages], ); if (sortedFaces.length === 0) { return (
{t("nofaces")}
); } return (
{sortedFaces.map((image: string) => (
onClickFaces([data.filename], meta)} > { e.stopPropagation(); onDelete(pageToggle, [image]); }} /> {t("button.deleteFaceAttempts")}
))}
); }