Face tweaks (#17225)

* Always use white text

* Add right click as well

* Add face details dialog

* Clenaup
This commit is contained in:
Nicolas Mowen 2025-03-18 08:32:15 -06:00 committed by GitHub
parent 9f7ba51f39
commit 5514fc11b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 113 additions and 23 deletions

View File

@ -2,6 +2,12 @@
"description": { "description": {
"addFace": "Walk through adding a new face to the Face Library." "addFace": "Walk through adding a new face to the Face Library."
}, },
"details": {
"confidence": "Confidence",
"face": "Face Details",
"faceDesc": "Details for the face and associated object",
"timestamp": "Timestamp"
},
"documentTitle": "Face Library - Frigate", "documentTitle": "Face Library - Frigate",
"uploadFaceImage": { "uploadFaceImage": {
"title": "Upload Face Image", "title": "Upload Face Image",

View File

@ -5,6 +5,13 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
import CreateFaceWizardDialog from "@/components/overlay/detail/FaceCreateWizardDialog"; import CreateFaceWizardDialog from "@/components/overlay/detail/FaceCreateWizardDialog";
import UploadImageDialog from "@/components/overlay/dialog/UploadImageDialog"; import UploadImageDialog from "@/components/overlay/dialog/UploadImageDialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -20,9 +27,12 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } 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 useKeyboardListener from "@/hooks/use-keyboard-listener";
import useOptimisticState from "@/hooks/use-optimistic-state"; import useOptimisticState from "@/hooks/use-optimistic-state";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { RecognizedFaceData } from "@/types/face";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import axios from "axios"; import axios from "axios";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -329,20 +339,76 @@ function TrainingGrid({
onClickFace, onClickFace,
onRefresh, onRefresh,
}: TrainingGridProps) { }: TrainingGridProps) {
const { t } = useTranslation(["views/faceLibrary"]);
// face data
const [selectedEvent, setSelectedEvent] = useState<RecognizedFaceData>();
const formattedDate = useFormattedTimestamp(
selectedEvent?.timestamp ?? 0,
config?.ui.time_format == "24hour"
? t("time.formattedTimestampWithYear.24hour", { ns: "common" })
: t("time.formattedTimestampWithYear.12hour", { ns: "common" }),
config?.ui.timezone,
);
return ( return (
<div className="scrollbar-container flex flex-wrap gap-2 overflow-y-scroll p-1"> <>
{attemptImages.map((image: string) => ( <Dialog
<FaceAttempt open={selectedEvent != undefined}
key={image} onOpenChange={(open) => {
image={image} if (!open) {
faceNames={faceNames} setSelectedEvent(undefined);
threshold={config.face_recognition.recognition_threshold} }
selected={selectedFaces.includes(image)} }}
onClick={(meta) => onClickFace(image, meta)} >
onRefresh={onRefresh} <DialogContent>
/> <DialogHeader>
))} <DialogTitle>{t("details.face")}</DialogTitle>
</div> <DialogDescription>{t("details.faceDesc")}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">
{t("details.confidence")}
</div>
<div className="text-sm capitalize">
{(selectedEvent?.score || 0) * 100}%
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">
{t("details.timestamp")}
</div>
<div className="text-sm">{formattedDate}</div>
</div>
<img
className="w-full"
src={`${baseUrl}api/events/${selectedEvent?.eventId}/thumbnail.jpg`}
/>
</DialogContent>
</Dialog>
<div className="scrollbar-container flex flex-wrap gap-2 overflow-y-scroll p-1">
{attemptImages.map((image: string) => (
<FaceAttempt
key={image}
image={image}
faceNames={faceNames}
threshold={config.face_recognition.recognition_threshold}
selected={selectedFaces.includes(image)}
onClick={(data, meta) => {
if (meta) {
onClickFace(image, meta);
} else {
setSelectedEvent(data);
}
}}
onRefresh={onRefresh}
/>
))}
</div>
</>
); );
} }
@ -351,7 +417,7 @@ type FaceAttemptProps = {
faceNames: string[]; faceNames: string[];
threshold: number; threshold: number;
selected: boolean; selected: boolean;
onClick: (meta: boolean) => void; onClick: (data: RecognizedFaceData, meta: boolean) => void;
onRefresh: () => void; onRefresh: () => void;
}; };
function FaceAttempt({ function FaceAttempt({
@ -363,17 +429,27 @@ function FaceAttempt({
onRefresh, onRefresh,
}: FaceAttemptProps) { }: FaceAttemptProps) {
const { t } = useTranslation(["views/faceLibrary"]); const { t } = useTranslation(["views/faceLibrary"]);
const data = useMemo(() => { const data = useMemo<RecognizedFaceData>(() => {
const parts = image.split("-"); const parts = image.split("-");
return { return {
timestamp: Number.parseFloat(parts[0]), timestamp: Number.parseFloat(parts[0]),
eventId: `${parts[0]}-${parts[1]}`, eventId: `${parts[0]}-${parts[1]}`,
name: parts[2], name: parts[2],
score: parts[3], score: Number.parseFloat(parts[3]),
}; };
}, [image]); }, [image]);
// interaction
const imgRef = useRef<HTMLImageElement | null>(null);
useContextMenu(imgRef, () => {
onClick(data, true);
});
// api calls
const onTrainAttempt = useCallback( const onTrainAttempt = useCallback(
(trainName: string) => { (trainName: string) => {
axios axios
@ -429,12 +505,16 @@ function FaceAttempt({
? "shadow-selected outline-selected" ? "shadow-selected outline-selected"
: "outline-transparent duration-500", : "outline-transparent duration-500",
)} )}
onClick={(e) => onClick(e.metaKey || e.ctrlKey)}
> >
<div className="relative w-full overflow-hidden rounded-t-lg border border-t-0 *:text-card-foreground"> <div className="relative w-full overflow-hidden rounded-t-lg border border-t-0 *:text-card-foreground">
<img className="size-44" src={`${baseUrl}clips/faces/train/${image}`} /> <img
ref={imgRef}
className="size-44"
src={`${baseUrl}clips/faces/train/${image}`}
onClick={(e) => onClick(data, e.metaKey || e.ctrlKey)}
/>
<div className="absolute bottom-1 right-1 z-10 rounded-lg bg-black/50 px-2 py-1 text-xs text-white"> <div className="absolute bottom-1 right-1 z-10 rounded-lg bg-black/50 px-2 py-1 text-xs text-white">
<TimeAgo time={data.timestamp * 1000} dense /> <TimeAgo className="text-white" time={data.timestamp * 1000} dense />
</div> </div>
</div> </div>
<div className="rounded-b-lg bg-card p-2"> <div className="rounded-b-lg bg-card p-2">
@ -443,12 +523,10 @@ function FaceAttempt({
<div className="capitalize">{data.name}</div> <div className="capitalize">{data.name}</div>
<div <div
className={cn( className={cn(
Number.parseFloat(data.score) >= threshold data.score >= threshold ? "text-success" : "text-danger",
? "text-success"
: "text-danger",
)} )}
> >
{Number.parseFloat(data.score) * 100}% {data.score * 100}%
</div> </div>
</div> </div>
<div className="flex flex-row items-start justify-end gap-5 md:gap-4"> <div className="flex flex-row items-start justify-end gap-5 md:gap-4">

6
web/src/types/face.ts Normal file
View File

@ -0,0 +1,6 @@
export type RecognizedFaceData = {
timestamp: number;
eventId: string;
name: string;
score: number;
};