Face multi select (#17068)

* Implement multi select for face library

* Clear list of selected

* Add keyboard shortcut
This commit is contained in:
Nicolas Mowen 2025-03-10 09:01:52 -06:00 committed by GitHub
parent 2be5225440
commit 7d44970f78
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 117 additions and 44 deletions

View File

@ -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>

View File

@ -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>
); );