From 234c65b5c3dc491037474f85689ca7a016aa559e Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 4 Jun 2025 06:14:20 -0600 Subject: [PATCH] Add support for deleting images --- frigate/api/classification.py | 72 +++++++++++ .../locales/en/views/classificationModel.json | 32 ++++- .../classification/ModelSelectionView.tsx | 1 + .../classification/ModelTrainingView.tsx | 113 ++++++++++++++---- 4 files changed, 193 insertions(+), 25 deletions(-) diff --git a/frigate/api/classification.py b/frigate/api/classification.py index ec53a47af..43a1dd41e 100644 --- a/frigate/api/classification.py +++ b/frigate/api/classification.py @@ -497,3 +497,75 @@ async def train_configured_model( content={"success": True, "message": "Started classification model training."}, 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, + ) diff --git a/web/public/locales/en/views/classificationModel.json b/web/public/locales/en/views/classificationModel.json index f76bf7ce1..2d666a69f 100644 --- a/web/public/locales/en/views/classificationModel.json +++ b/web/public/locales/en/views/classificationModel.json @@ -1,5 +1,33 @@ { "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" } diff --git a/web/src/views/classification/ModelSelectionView.tsx b/web/src/views/classification/ModelSelectionView.tsx index fdc83dc82..63133842a 100644 --- a/web/src/views/classification/ModelSelectionView.tsx +++ b/web/src/views/classification/ModelSelectionView.tsx @@ -41,6 +41,7 @@ export default function ModelSelectionView({ key={config.name} className={cn( "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", )} onClick={() => onClick(config)} diff --git a/web/src/views/classification/ModelTrainingView.tsx b/web/src/views/classification/ModelTrainingView.tsx index 1a76b5c5e..02ae761c8 100644 --- a/web/src/views/classification/ModelTrainingView.tsx +++ b/web/src/views/classification/ModelTrainingView.tsx @@ -15,6 +15,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Toaster } from "@/components/ui/sonner"; import { Tooltip, TooltipContent, @@ -29,23 +30,25 @@ import { useCallback, useMemo, useState } from "react"; import { isMobile } from "react-device-detect"; import { useTranslation } from "react-i18next"; import { LuPencil, LuTrash2 } from "react-icons/lu"; +import { toast } from "sonner"; import useSWR from "swr"; type ModelTrainingViewProps = { model: CustomClassificationModelConfig; }; export default function ModelTrainingView({ model }: ModelTrainingViewProps) { + const { t } = useTranslation(["views/classificationModel"]); const [page, setPage] = useState("train"); const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); // dataset - const { data: trainImages } = useSWR( + const { data: trainImages, mutate: refreshTrain } = useSWR( `classification/${model.name}/train`, ); - const { data: dataset } = useSWR<{ [id: string]: string[] }>( - `classification/${model.name}/dataset`, - ); + const { data: dataset, mutate: refreshDataset } = useSWR<{ + [id: string]: string[]; + }>(`classification/${model.name}/dataset`); // actions @@ -53,15 +56,75 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) { axios.post(`classification/${model.name}/train`); }, [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 ( -
-
+
+ + +
{}} + onDelete={onDelete} onRename={() => {}} /> @@ -72,14 +135,14 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) { trainImages={trainImages || []} selected={false} onClickImages={() => {}} - onDelete={() => {}} + onDelete={onDelete} /> ) : ( {}} + onDelete={onDelete} /> )}
@@ -91,7 +154,7 @@ type LibrarySelectorProps = { dataset: { [id: string]: string[] }; trainImages: string[]; 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; }; function LibrarySelector({ @@ -102,7 +165,7 @@ function LibrarySelector({ onDelete, onRename, }: LibrarySelectorProps) { - const { t } = useTranslation(["views/faceLibrary"]); + const { t } = useTranslation(["views/classificationModel"]); const [confirmDelete, setConfirmDelete] = useState(null); const [renameFace, setRenameFace] = useState(null); @@ -111,7 +174,7 @@ function LibrarySelector({ // Get all image IDs for this face const imageIds = dataset?.[name] || []; - onDelete(name, imageIds, true); + onDelete(imageIds, true); setPageToggle("train"); }, [dataset, onDelete, setPageToggle], @@ -132,9 +195,9 @@ function LibrarySelector({ > - {t("deleteFaceLibrary.title")} + {t("deleteCategory.title")} - {t("deleteFaceLibrary.desc", { name: confirmDelete })} + {t("deleteCategory.desc", { name: confirmDelete })}
@@ -159,8 +222,8 @@ function LibrarySelector({ { onRename(renameFace!, newName); setRenameFace(null); @@ -203,7 +266,7 @@ function LibrarySelector({ <>
- {t("collections")} + {t("categories")}
)} @@ -237,7 +300,9 @@ function LibrarySelector({ - {t("button.renameFace")} + + {t("button.renameCategory")} + @@ -255,7 +320,9 @@ function LibrarySelector({ - {t("button.deleteFace")} + + {t("button.deleteCategory")} +
@@ -271,7 +338,7 @@ type DatasetGridProps = { modelName: string; categoryName: string; images: string[]; - onDelete: (modelName: string, categoryName: string, ids: string[]) => void; + onDelete: (ids: string[]) => void; }; function DatasetGrid({ modelName, @@ -314,7 +381,7 @@ function DatasetGrid({ className="size-5 cursor-pointer text-primary-variant hover:text-primary" onClick={(e) => { e.stopPropagation(); - onDelete(modelName, categoryName, [image]); + onDelete([image]); }} /> @@ -336,7 +403,7 @@ type TrainGridProps = { trainImages: string[]; selected: boolean; onClickImages: (images: string[], ctrl: boolean) => void; - onDelete: (name: string, ids: string[]) => void; + onDelete: (ids: string[]) => void; }; function TrainGrid({ model, @@ -401,7 +468,7 @@ function TrainGrid({ className="size-5 cursor-pointer text-primary-variant hover:text-primary" onClick={(e) => { e.stopPropagation(); - onDelete("train", [data.raw]); + onDelete([data.raw]); }} />