Add support for deleting images

This commit is contained in:
Nicolas Mowen 2025-06-04 06:14:20 -06:00
parent 3420de88be
commit 234c65b5c3
4 changed files with 193 additions and 25 deletions

View File

@ -497,3 +497,75 @@ async def train_configured_model(
content={"success": True, "message": "Started classification model training."}, content={"success": True, "message": "Started classification model training."},
status_code=200, status_code=200,
) )
@router.post(
"/classification/{name}/dataset/{category}/delete",
dependencies=[Depends(require_role(["admin"]))],
)
def delete_classification_dataset_images(
request: Request, name: str, category: str, body: dict = None
):
config: FrigateConfig = request.app.frigate_config
if name not in config.classification.custom:
return JSONResponse(
content=(
{
"success": False,
"message": f"{name} is not a known classification model.",
}
),
status_code=404,
)
json: dict[str, Any] = body or {}
list_of_ids = json.get("ids", "")
folder = os.path.join(
CLIPS_DIR, sanitize_filename(name), "dataset", sanitize_filename(category)
)
for id in list_of_ids:
file_path = os.path.join(folder, id)
if os.path.isfile(file_path):
os.unlink(file_path)
return JSONResponse(
content=({"success": True, "message": "Successfully deleted faces."}),
status_code=200,
)
@router.post(
"/classification/{name}/train/delete",
dependencies=[Depends(require_role(["admin"]))],
)
def delete_classification_train_images(request: Request, name: str, body: dict = None):
config: FrigateConfig = request.app.frigate_config
if name not in config.classification.custom:
return JSONResponse(
content=(
{
"success": False,
"message": f"{name} is not a known classification model.",
}
),
status_code=404,
)
json: dict[str, Any] = body or {}
list_of_ids = json.get("ids", "")
folder = os.path.join(CLIPS_DIR, sanitize_filename(name), "train")
for id in list_of_ids:
file_path = os.path.join(folder, id)
if os.path.isfile(file_path):
os.unlink(file_path)
return JSONResponse(
content=({"success": True, "message": "Successfully deleted faces."}),
status_code=200,
)

View File

@ -1,5 +1,33 @@
{ {
"button": { "button": {
"deleteClassificationAttempts": "Delete Classification Images" "deleteClassificationAttempts": "Delete Classification Images",
} "renameCategory": "Rename Category",
"deleteCategory": "Rename Category"
},
"toast": {
"success": {
"deletedCategory": "Deleted Category",
"deletedImage": "Deleted Images"
},
"error": {
"deleteImageFailed": "Failed to delete: {{errorMessage}}",
"deleteCategoryFailed": "Failed to delete category: {{errorMessage}}"
}
},
"deleteCategory": {
"title": "Delete Category",
"desc": "Are you sure you want to delete the category {{name}}? This will permanently delete all associated images and require re-training the model."
},
"renameCategory": {
"title": "Rename Category",
"desc": "Enter a new name for {{name}}. You will be required to retrain the model for the name change to take affect."
},
"description": {
"invalidName": " \"invalidName\": \"Invalid name. Names can only include letters, numbers, spaces, apostrophes, underscores, and hyphens.\""
},
"train": {
"title": "Train",
"aria": "Select Train"
},
"categories": "Categories"
} }

View File

@ -41,6 +41,7 @@ export default function ModelSelectionView({
key={config.name} key={config.name}
className={cn( className={cn(
"flex h-52 cursor-pointer flex-col gap-2 rounded-lg bg-card p-2 outline outline-[3px]", "flex h-52 cursor-pointer flex-col gap-2 rounded-lg bg-card p-2 outline outline-[3px]",
"outline-transparent duration-500",
isMobile && "w-full", isMobile && "w-full",
)} )}
onClick={() => onClick(config)} onClick={() => onClick(config)}

View File

@ -15,6 +15,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Toaster } from "@/components/ui/sonner";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@ -29,23 +30,25 @@ import { useCallback, useMemo, useState } from "react";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LuPencil, LuTrash2 } from "react-icons/lu"; import { LuPencil, LuTrash2 } from "react-icons/lu";
import { toast } from "sonner";
import useSWR from "swr"; import useSWR from "swr";
type ModelTrainingViewProps = { type ModelTrainingViewProps = {
model: CustomClassificationModelConfig; model: CustomClassificationModelConfig;
}; };
export default function ModelTrainingView({ model }: ModelTrainingViewProps) { export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
const { t } = useTranslation(["views/classificationModel"]);
const [page, setPage] = useState<string>("train"); const [page, setPage] = useState<string>("train");
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
// dataset // dataset
const { data: trainImages } = useSWR<string[]>( const { data: trainImages, mutate: refreshTrain } = useSWR<string[]>(
`classification/${model.name}/train`, `classification/${model.name}/train`,
); );
const { data: dataset } = useSWR<{ [id: string]: string[] }>( const { data: dataset, mutate: refreshDataset } = useSWR<{
`classification/${model.name}/dataset`, [id: string]: string[];
); }>(`classification/${model.name}/dataset`);
// actions // actions
@ -53,15 +56,75 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
axios.post(`classification/${model.name}/train`); axios.post(`classification/${model.name}/train`);
}, [model]); }, [model]);
const onDelete = useCallback(
(ids: string[], isName: boolean = false) => {
const api =
pageToggle == "train"
? `/classification/${model.name}/train/delete`
: `/classification/${model.name}/dataset/${pageToggle}/delete`;
axios
.post(api, { ids })
.then((resp) => {
//setSelectedFaces([]);
if (resp.status == 200) {
if (isName) {
toast.success(
t("toast.success.deletedCategory", { count: ids.length }),
{
position: "top-center",
},
);
} else {
toast.success(
t("toast.success.deletedImage", { count: ids.length }),
{
position: "top-center",
},
);
}
if (pageToggle == "train") {
refreshTrain();
} else {
refreshDataset();
}
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
if (isName) {
toast.error(
t("toast.error.deleteCategoryFailed", { errorMessage }),
{
position: "top-center",
},
);
} else {
toast.error(t("toast.error.deleteImageFailed", { errorMessage }), {
position: "top-center",
});
}
});
},
[pageToggle, model, refreshTrain, refreshDataset, t],
);
return ( return (
<div className="flex size-full flex-col gap-2 overflow-hidden p-2"> <div className="flex size-full flex-col overflow-hidden p-2">
<div className="flex flex-row justify-between gap-2 align-middle"> <Toaster />
<div className="mb-2 flex flex-row justify-between gap-2 align-middle">
<LibrarySelector <LibrarySelector
pageToggle={pageToggle} pageToggle={pageToggle}
dataset={dataset || {}} dataset={dataset || {}}
trainImages={trainImages || []} trainImages={trainImages || []}
setPageToggle={setPageToggle} setPageToggle={setPageToggle}
onDelete={() => {}} onDelete={onDelete}
onRename={() => {}} onRename={() => {}}
/> />
<Button onClick={trainModel}>Train Model</Button> <Button onClick={trainModel}>Train Model</Button>
@ -72,14 +135,14 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
trainImages={trainImages || []} trainImages={trainImages || []}
selected={false} selected={false}
onClickImages={() => {}} onClickImages={() => {}}
onDelete={() => {}} onDelete={onDelete}
/> />
) : ( ) : (
<DatasetGrid <DatasetGrid
modelName={model.name} modelName={model.name}
categoryName={pageToggle} categoryName={pageToggle}
images={dataset?.[pageToggle] || []} images={dataset?.[pageToggle] || []}
onDelete={() => {}} onDelete={onDelete}
/> />
)} )}
</div> </div>
@ -91,7 +154,7 @@ type LibrarySelectorProps = {
dataset: { [id: string]: string[] }; dataset: { [id: string]: string[] };
trainImages: string[]; trainImages: string[];
setPageToggle: (toggle: string) => void; setPageToggle: (toggle: string) => void;
onDelete: (name: string, ids: string[], isName: boolean) => void; onDelete: (ids: string[], isName: boolean) => void;
onRename: (old_name: string, new_name: string) => void; onRename: (old_name: string, new_name: string) => void;
}; };
function LibrarySelector({ function LibrarySelector({
@ -102,7 +165,7 @@ function LibrarySelector({
onDelete, onDelete,
onRename, onRename,
}: LibrarySelectorProps) { }: LibrarySelectorProps) {
const { t } = useTranslation(["views/faceLibrary"]); const { t } = useTranslation(["views/classificationModel"]);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null); const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [renameFace, setRenameFace] = useState<string | null>(null); const [renameFace, setRenameFace] = useState<string | null>(null);
@ -111,7 +174,7 @@ function LibrarySelector({
// Get all image IDs for this face // Get all image IDs for this face
const imageIds = dataset?.[name] || []; const imageIds = dataset?.[name] || [];
onDelete(name, imageIds, true); onDelete(imageIds, true);
setPageToggle("train"); setPageToggle("train");
}, },
[dataset, onDelete, setPageToggle], [dataset, onDelete, setPageToggle],
@ -132,9 +195,9 @@ function LibrarySelector({
> >
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t("deleteFaceLibrary.title")}</DialogTitle> <DialogTitle>{t("deleteCategory.title")}</DialogTitle>
<DialogDescription> <DialogDescription>
{t("deleteFaceLibrary.desc", { name: confirmDelete })} {t("deleteCategory.desc", { name: confirmDelete })}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
@ -159,8 +222,8 @@ function LibrarySelector({
<TextEntryDialog <TextEntryDialog
open={!!renameFace} open={!!renameFace}
setOpen={handleSetOpen} setOpen={handleSetOpen}
title={t("renameFace.title")} title={t("renameCategory.title")}
description={t("renameFace.desc", { name: renameFace })} description={t("renameCategory.desc", { name: renameFace })}
onSave={(newName) => { onSave={(newName) => {
onRename(renameFace!, newName); onRename(renameFace!, newName);
setRenameFace(null); setRenameFace(null);
@ -203,7 +266,7 @@ function LibrarySelector({
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="mb-1 ml-1.5 text-xs text-secondary-foreground"> <div className="mb-1 ml-1.5 text-xs text-secondary-foreground">
{t("collections")} {t("categories")}
</div> </div>
</> </>
)} )}
@ -237,7 +300,9 @@ function LibrarySelector({
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipPortal> <TooltipPortal>
<TooltipContent>{t("button.renameFace")}</TooltipContent> <TooltipContent>
{t("button.renameCategory")}
</TooltipContent>
</TooltipPortal> </TooltipPortal>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
@ -255,7 +320,9 @@ function LibrarySelector({
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipPortal> <TooltipPortal>
<TooltipContent>{t("button.deleteFace")}</TooltipContent> <TooltipContent>
{t("button.deleteCategory")}
</TooltipContent>
</TooltipPortal> </TooltipPortal>
</Tooltip> </Tooltip>
</div> </div>
@ -271,7 +338,7 @@ type DatasetGridProps = {
modelName: string; modelName: string;
categoryName: string; categoryName: string;
images: string[]; images: string[];
onDelete: (modelName: string, categoryName: string, ids: string[]) => void; onDelete: (ids: string[]) => void;
}; };
function DatasetGrid({ function DatasetGrid({
modelName, modelName,
@ -314,7 +381,7 @@ function DatasetGrid({
className="size-5 cursor-pointer text-primary-variant hover:text-primary" className="size-5 cursor-pointer text-primary-variant hover:text-primary"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onDelete(modelName, categoryName, [image]); onDelete([image]);
}} }}
/> />
</TooltipTrigger> </TooltipTrigger>
@ -336,7 +403,7 @@ type TrainGridProps = {
trainImages: string[]; trainImages: string[];
selected: boolean; selected: boolean;
onClickImages: (images: string[], ctrl: boolean) => void; onClickImages: (images: string[], ctrl: boolean) => void;
onDelete: (name: string, ids: string[]) => void; onDelete: (ids: string[]) => void;
}; };
function TrainGrid({ function TrainGrid({
model, model,
@ -401,7 +468,7 @@ function TrainGrid({
className="size-5 cursor-pointer text-primary-variant hover:text-primary" className="size-5 cursor-pointer text-primary-variant hover:text-primary"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onDelete("train", [data.raw]); onDelete([data.raw]);
}} }}
/> />
</TooltipTrigger> </TooltipTrigger>