import { baseUrl } from "@/api/baseUrl"; import TimeAgo from "@/components/dynamic/TimeAgo"; 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 { 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 { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Toaster } from "@/components/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import useContextMenu from "@/hooks/use-contextmenu"; import { useFormattedTimestamp } from "@/hooks/use-date-utils"; 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, RecognizedFaceData } from "@/types/face"; import { FaceRecognitionConfig, FrigateConfig } from "@/types/frigateConfig"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import axios from "axios"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isDesktop, isMobile } from "react-device-detect"; import { Trans, useTranslation } from "react-i18next"; import { LuFolderCheck, LuImagePlus, LuInfo, LuPencil, LuRefreshCw, LuScanFace, LuSearch, LuTrash2, } from "react-icons/lu"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import useSWR from "swr"; 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") : [], [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); 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 useKeyboardListener(["a", "Escape"], (key, modifiers) => { if (modifiers.repeat || !modifiers.down) { return; } switch (key) { case "a": if (modifiers.ctrl) { if (selectedFaces.length) { setSelectedFaces([]); } else { setSelectedFaces([ ...(pageToggle === "train" ? trainImages : faceImages), ]); } } break; case "Escape": setSelectedFaces([]); break; } }); 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 || ""} /> 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; attemptImages: string[]; faceNames: string[]; selectedFaces: string[]; onClickFaces: (images: string[], ctrl: boolean) => void; onRefresh: () => void; }; function TrainingGrid({ config, attemptImages, faceNames, selectedFaces, onClickFaces, onRefresh, }: TrainingGridProps) { const { t } = useTranslation(["views/faceLibrary"]); // face data const faceGroups = useMemo(() => { const groups: { [eventId: string]: RecognizedFaceData[] } = {}; const faces = attemptImages .map((image) => { const parts = image.split("-"); try { return { filename: 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 }, ]); // selection const [selectedEvent, setSelectedEvent] = useState(); const formattedDate = useFormattedTimestamp( selectedEvent?.start_time ?? 0, config?.ui.time_format == "24hour" ? t("time.formattedTimestampMonthDayYearHourMinute.24hour", { ns: "common", }) : t("time.formattedTimestampMonthDayYearHourMinute.12hour", { ns: "common", }), config?.ui.timezone, ); if (attemptImages.length == 0) { return (
{t("train.empty")}
); } return ( <> { if (!open) { setSelectedEvent(undefined); } }} > {t("details.face")} {t("details.faceDesc")}
{t("details.person")}
{selectedEvent?.sub_label ?? t("details.unknown")}
{selectedEvent?.data.sub_label_score && (
{t("details.subLabelScore")}
Info
{t("details.scoreInfo")}
{Math.round((selectedEvent?.data?.sub_label_score || 0) * 100)}%
)}
{t("details.timestamp")}
{formattedDate}
{Object.entries(faceGroups).map(([key, group]) => { const event = events?.find((ev) => ev.id == key); return ( ); })}
); } type FaceAttemptGroupProps = { config: FrigateConfig; group: RecognizedFaceData[]; event?: Event; faceNames: string[]; selectedFaces: string[]; onClickFaces: (image: string[], ctrl: boolean) => void; onSelectEvent: (event: Event) => void; onRefresh: () => void; }; function FaceAttemptGroup({ config, group, event, faceNames, selectedFaces, onClickFaces, onSelectEvent, onRefresh, }: FaceAttemptGroupProps) { const navigate = useNavigate(); const { t } = useTranslation(["views/faceLibrary", "views/explore"]); // data const allFacesSelected = useMemo( () => group.every((face) => selectedFaces.includes(face.filename)), [group, selectedFaces], ); // interaction const handleClickEvent = useCallback( (meta: boolean) => { if (event && selectedFaces.length == 0 && !meta) { onSelectEvent(event); } 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, ); } } }, [event, group, selectedFaces, onClickFaces, onSelectEvent], ); return (
{ if (selectedFaces.length) { handleClickEvent(true); } }} onContextMenu={(e) => { e.stopPropagation(); e.preventDefault(); handleClickEvent(true); }} >
Person {event?.sub_label ? `: ${event.sub_label} (${Math.round((event.data.sub_label_score || 0) * 100)}%)` : ": " + t("details.unknown")}
{event && (
{ navigate(`/explore?event_id=${event.id}`); }} >
{t("details.item.button.viewInExplore", { ns: "views/explore", })}
)}
{group.map((data: RecognizedFaceData) => ( { if (meta || selectedFaces.length > 0) { onClickFaces([data.filename], true); } else if (event) { onSelectEvent(event); } }} onRefresh={onRefresh} /> ))}
); } type FaceAttemptProps = { data: RecognizedFaceData; faceNames: string[]; recognitionConfig: FaceRecognitionConfig; selected: boolean; onClick: (data: RecognizedFaceData, meta: boolean) => void; onRefresh: () => void; }; function FaceAttempt({ data, faceNames, recognitionConfig, selected, onClick, onRefresh, }: FaceAttemptProps) { const { t } = useTranslation(["views/faceLibrary"]); const scoreStatus = useMemo(() => { if (data.score >= recognitionConfig.recognition_threshold) { return "match"; } else if (data.score >= recognitionConfig.unknown_score) { return "potential"; } else { return "unknown"; } }, [data, recognitionConfig]); // interaction const imgRef = useRef(null); useContextMenu(imgRef, () => { onClick(data, true); }); // api calls const onTrainAttempt = useCallback( (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", }); }); }, [data, onRefresh, t], ); const onReprocess = useCallback(() => { 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", }); }); }, [data, onRefresh, t]); return ( <>
{ e.stopPropagation(); onClick(data, e.metaKey || e.ctrlKey); }} />
{data.name == "unknown" ? t("details.unknown") : data.name}
{Math.round(data.score * 100)}%
onReprocess()} /> {t("button.reprocessFace")}
); } type FaceGridProps = { faceImages: string[]; pageToggle: string; selectedFaces: string[]; onClickFaces: (images: string[], ctrl: boolean) => void; onDelete: (name: string, ids: string[]) => void; }; function FaceGrid({ faceImages, pageToggle, selectedFaces, onClickFaces, onDelete, }: FaceGridProps) { const sortedFaces = useMemo( () => (faceImages || []).sort().reverse(), [faceImages], ); if (sortedFaces.length === 0) { return (
No faces available
); } return (
{sortedFaces.map((image: string) => ( ))}
); } type FaceImageProps = { name: string; image: string; selected: boolean; onClickFaces: (images: string[], ctrl: boolean) => void; onDelete: (name: string, ids: string[]) => void; }; function FaceImage({ name, image, selected, onClickFaces, onDelete, }: FaceImageProps) { const { t } = useTranslation(["views/faceLibrary"]); return (
{ e.stopPropagation(); onClickFaces([image], e.ctrlKey || e.metaKey); }} >
{name}
{ e.stopPropagation(); onDelete(name, [image]); }} /> {t("button.deleteFaceAttempts")}
); }