mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-30 13:48:07 +02:00
Face multi select (#17068)
* Implement multi select for face library * Clear list of selected * Add keyboard shortcut
This commit is contained in:
parent
2be5225440
commit
7d44970f78
@ -19,6 +19,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
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 { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
@ -141,6 +142,73 @@ export default function FaceLibrary() {
|
|||||||
[refreshFaces],
|
[refreshFaces],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// face multiselect
|
||||||
|
|
||||||
|
const [selectedFaces, setSelectedFaces] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const onClickFace = useCallback(
|
||||||
|
(imageId: string) => {
|
||||||
|
const index = selectedFaces.indexOf(imageId);
|
||||||
|
|
||||||
|
if (index != -1) {
|
||||||
|
if (selectedFaces.length == 1) {
|
||||||
|
setSelectedFaces([]);
|
||||||
|
} else {
|
||||||
|
const copy = [
|
||||||
|
...selectedFaces.slice(0, index),
|
||||||
|
...selectedFaces.slice(index + 1),
|
||||||
|
];
|
||||||
|
setSelectedFaces(copy);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const copy = [...selectedFaces];
|
||||||
|
copy.push(imageId);
|
||||||
|
setSelectedFaces(copy);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedFaces, setSelectedFaces],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDelete = useCallback(() => {
|
||||||
|
axios
|
||||||
|
.post(`/faces/train/delete`, { ids: selectedFaces })
|
||||||
|
.then((resp) => {
|
||||||
|
setSelectedFaces([]);
|
||||||
|
|
||||||
|
if (resp.status == 200) {
|
||||||
|
toast.success(`Successfully deleted face.`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
refreshFaces();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const errorMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(`Failed to delete: ${errorMessage}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [selectedFaces, refreshFaces]);
|
||||||
|
|
||||||
|
// keyboard
|
||||||
|
|
||||||
|
useKeyboardListener(["a"], (key, modifiers) => {
|
||||||
|
if (modifiers.repeat || !modifiers.down) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case "a":
|
||||||
|
if (modifiers.ctrl) {
|
||||||
|
setSelectedFaces([...trainImages]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return <ActivityIndicator />;
|
return <ActivityIndicator />;
|
||||||
}
|
}
|
||||||
@ -210,16 +278,27 @@ export default function FaceLibrary() {
|
|||||||
<ScrollBar orientation="horizontal" className="h-0" />
|
<ScrollBar orientation="horizontal" className="h-0" />
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
<div className="flex items-center justify-center gap-2">
|
{selectedFaces?.length > 0 ? (
|
||||||
<Button className="flex gap-2" onClick={() => setAddFace(true)}>
|
<div className="flex items-center justify-center gap-2">
|
||||||
<LuScanFace className="size-7 rounded-md p-1 text-secondary-foreground" />
|
<Button className="flex gap-2" onClick={() => onDelete()}>
|
||||||
Add Face
|
<LuTrash2 className="size-7 rounded-md p-1 text-secondary-foreground" />
|
||||||
</Button>
|
Delete Face Attempts
|
||||||
<Button className="flex gap-2" onClick={() => setUpload(true)}>
|
</Button>
|
||||||
<LuImagePlus className="size-7 rounded-md p-1 text-secondary-foreground" />
|
</div>
|
||||||
Upload Image
|
) : (
|
||||||
</Button>
|
<div className="flex items-center justify-center gap-2">
|
||||||
</div>
|
<Button className="flex gap-2" onClick={() => setAddFace(true)}>
|
||||||
|
<LuScanFace className="size-7 rounded-md p-1 text-secondary-foreground" />
|
||||||
|
Add Face
|
||||||
|
</Button>
|
||||||
|
{pageToggle != "train" && (
|
||||||
|
<Button className="flex gap-2" onClick={() => setUpload(true)}>
|
||||||
|
<LuImagePlus className="size-7 rounded-md p-1 text-secondary-foreground" />
|
||||||
|
Upload Image
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{pageToggle &&
|
{pageToggle &&
|
||||||
(pageToggle == "train" ? (
|
(pageToggle == "train" ? (
|
||||||
@ -227,6 +306,8 @@ export default function FaceLibrary() {
|
|||||||
config={config}
|
config={config}
|
||||||
attemptImages={trainImages}
|
attemptImages={trainImages}
|
||||||
faceNames={faces}
|
faceNames={faces}
|
||||||
|
selectedFaces={selectedFaces}
|
||||||
|
onClickFace={onClickFace}
|
||||||
onRefresh={refreshFaces}
|
onRefresh={refreshFaces}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@ -244,22 +325,28 @@ type TrainingGridProps = {
|
|||||||
config: FrigateConfig;
|
config: FrigateConfig;
|
||||||
attemptImages: string[];
|
attemptImages: string[];
|
||||||
faceNames: string[];
|
faceNames: string[];
|
||||||
|
selectedFaces: string[];
|
||||||
|
onClickFace: (image: string) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
};
|
};
|
||||||
function TrainingGrid({
|
function TrainingGrid({
|
||||||
config,
|
config,
|
||||||
attemptImages,
|
attemptImages,
|
||||||
faceNames,
|
faceNames,
|
||||||
|
selectedFaces,
|
||||||
|
onClickFace,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: TrainingGridProps) {
|
}: TrainingGridProps) {
|
||||||
return (
|
return (
|
||||||
<div className="scrollbar-container flex flex-wrap gap-2 overflow-y-scroll">
|
<div className="scrollbar-container flex flex-wrap gap-2 overflow-y-scroll p-1">
|
||||||
{attemptImages.map((image: string) => (
|
{attemptImages.map((image: string) => (
|
||||||
<FaceAttempt
|
<FaceAttempt
|
||||||
key={image}
|
key={image}
|
||||||
image={image}
|
image={image}
|
||||||
faceNames={faceNames}
|
faceNames={faceNames}
|
||||||
threshold={config.face_recognition.threshold}
|
threshold={config.face_recognition.threshold}
|
||||||
|
selected={selectedFaces.includes(image)}
|
||||||
|
onClick={() => onClickFace(image)}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -271,12 +358,16 @@ type FaceAttemptProps = {
|
|||||||
image: string;
|
image: string;
|
||||||
faceNames: string[];
|
faceNames: string[];
|
||||||
threshold: number;
|
threshold: number;
|
||||||
|
selected: boolean;
|
||||||
|
onClick: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
};
|
};
|
||||||
function FaceAttempt({
|
function FaceAttempt({
|
||||||
image,
|
image,
|
||||||
faceNames,
|
faceNames,
|
||||||
threshold,
|
threshold,
|
||||||
|
selected,
|
||||||
|
onClick,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: FaceAttemptProps) {
|
}: FaceAttemptProps) {
|
||||||
const data = useMemo(() => {
|
const data = useMemo(() => {
|
||||||
@ -336,30 +427,16 @@ function FaceAttempt({
|
|||||||
});
|
});
|
||||||
}, [image, onRefresh]);
|
}, [image, onRefresh]);
|
||||||
|
|
||||||
const onDelete = useCallback(() => {
|
|
||||||
axios
|
|
||||||
.post(`/faces/train/delete`, { ids: [image] })
|
|
||||||
.then((resp) => {
|
|
||||||
if (resp.status == 200) {
|
|
||||||
toast.success(`Successfully deleted face.`, {
|
|
||||||
position: "top-center",
|
|
||||||
});
|
|
||||||
onRefresh();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
const errorMessage =
|
|
||||||
error.response?.data?.message ||
|
|
||||||
error.response?.data?.detail ||
|
|
||||||
"Unknown error";
|
|
||||||
toast.error(`Failed to delete: ${errorMessage}`, {
|
|
||||||
position: "top-center",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [image, onRefresh]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col rounded-lg">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-pointer flex-col rounded-lg outline outline-[3px]",
|
||||||
|
selected
|
||||||
|
? "shadow-selected outline-selected"
|
||||||
|
: "outline-transparent duration-500",
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
<div className="w-full overflow-hidden rounded-t-lg border border-t-0 *:text-card-foreground">
|
<div className="w-full overflow-hidden rounded-t-lg border border-t-0 *:text-card-foreground">
|
||||||
<img className="size-40" src={`${baseUrl}clips/faces/train/${image}`} />
|
<img className="size-40" src={`${baseUrl}clips/faces/train/${image}`} />
|
||||||
</div>
|
</div>
|
||||||
@ -409,15 +486,6 @@ function FaceAttempt({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Reprocess Face</TooltipContent>
|
<TooltipContent>Reprocess Face</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<LuTrash2
|
|
||||||
className="size-5 cursor-pointer text-primary-variant hover:text-primary"
|
|
||||||
onClick={onDelete}
|
|
||||||
/>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Delete Face Attempt</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -757,7 +757,12 @@ function DetectionReview({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`review-item-ring pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${selected ? `outline-severity_${value.severity} shadow-severity_${value.severity}` : "outline-transparent duration-500"}`}
|
className={cn(
|
||||||
|
"review-item-ring pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px]",
|
||||||
|
selected
|
||||||
|
? `outline-severity_${value.severity} shadow-severity_${value.severity}`
|
||||||
|
: "outline-transparent duration-500",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
Loading…
Reference in New Issue
Block a user