mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-09-14 17:52:10 +02:00
Face tweaks (#17225)
* Always use white text * Add right click as well * Add face details dialog * Clenaup
This commit is contained in:
parent
9f7ba51f39
commit
5514fc11b9
@ -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",
|
||||||
|
@ -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
6
web/src/types/face.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export type RecognizedFaceData = {
|
||||||
|
timestamp: number;
|
||||||
|
eventId: string;
|
||||||
|
name: string;
|
||||||
|
score: number;
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user