Classification fixes (#20771)

* Fully delete a model

* Fix deletion dialog

* Fix classification back step

* Adjust selection gradient

* Fix

* Fix
This commit is contained in:
Nicolas Mowen 2025-11-03 06:34:06 -07:00 committed by GitHub
parent d44340eca6
commit 4f76b34f44
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 54 additions and 26 deletions

View File

@ -31,13 +31,15 @@ from frigate.api.defs.response.generic_response import GenericResponse
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.camera import DetectConfig from frigate.config.camera import DetectConfig
from frigate.const import CLIPS_DIR, FACE_DIR from frigate.const import CLIPS_DIR, FACE_DIR, MODEL_CACHE_DIR
from frigate.embeddings import EmbeddingsContext from frigate.embeddings import EmbeddingsContext
from frigate.models import Event from frigate.models import Event
from frigate.util.builtin import update_yaml_file_bulk
from frigate.util.classification import ( from frigate.util.classification import (
collect_object_classification_examples, collect_object_classification_examples,
collect_state_classification_examples, collect_state_classification_examples,
) )
from frigate.util.config import find_config_file
from frigate.util.path import get_event_snapshot from frigate.util.path import get_event_snapshot
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -828,12 +830,34 @@ def delete_classification_model(request: Request, name: str):
status_code=404, status_code=404,
) )
# Delete the classification model's data directory # Delete the classification model's data directory in clips
model_dir = os.path.join(CLIPS_DIR, sanitize_filename(name)) data_dir = os.path.join(CLIPS_DIR, sanitize_filename(name))
if os.path.exists(data_dir):
shutil.rmtree(data_dir)
# Delete the classification model's files in model_cache
model_dir = os.path.join(MODEL_CACHE_DIR, sanitize_filename(name))
if os.path.exists(model_dir): if os.path.exists(model_dir):
shutil.rmtree(model_dir) shutil.rmtree(model_dir)
# Remove the model from the config file
config_file = find_config_file()
try:
# Setting value to empty string deletes the key
updates = {f"classification.custom.{name}": None}
update_yaml_file_bulk(config_file, updates)
except Exception as e:
logger.error(f"Error updating config file: {e}")
return JSONResponse(
content=(
{
"success": False,
"message": "Failed to update config file.",
}
),
status_code=500,
)
return JSONResponse( return JSONResponse(
content=( content=(
{ {

View File

@ -317,6 +317,21 @@ export default function Step3ChooseExamples({
return unclassifiedImages.length === 0; return unclassifiedImages.length === 0;
}, [unclassifiedImages]); }, [unclassifiedImages]);
const handleBack = useCallback(() => {
if (currentClassIndex > 0) {
const previousClass = allClasses[currentClassIndex - 1];
setCurrentClassIndex((prev) => prev - 1);
// Restore selections for the previous class
const previousSelections = Object.entries(imageClassifications)
.filter(([_, className]) => className === previousClass)
.map(([imageName, _]) => imageName);
setSelectedImages(new Set(previousSelections));
} else {
onBack();
}
}, [currentClassIndex, allClasses, imageClassifications, onBack]);
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{isTraining ? ( {isTraining ? (
@ -420,7 +435,7 @@ export default function Step3ChooseExamples({
{!isTraining && ( {!isTraining && (
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4"> <div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={onBack} className="sm:flex-1"> <Button type="button" onClick={handleBack} className="sm:flex-1">
{t("button.back", { ns: "common" })} {t("button.back", { ns: "common" })}
</Button> </Button>
<Button <Button

View File

@ -10,7 +10,7 @@ import {
CustomClassificationModelConfig, CustomClassificationModelConfig,
FrigateConfig, FrigateConfig,
} from "@/types/frigateConfig"; } from "@/types/frigateConfig";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FaFolderPlus } from "react-icons/fa"; import { FaFolderPlus } from "react-icons/fa";
import { MdModelTraining } from "react-icons/md"; import { MdModelTraining } from "react-icons/md";
@ -21,7 +21,6 @@ import Heading from "@/components/ui/heading";
import { useOverlayState } from "@/hooks/use-overlay-state"; import { useOverlayState } from "@/hooks/use-overlay-state";
import axios from "axios"; import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -212,12 +211,6 @@ function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
}>(`classification/${config.name}/dataset`, { revalidateOnFocus: false }); }>(`classification/${config.name}/dataset`, { revalidateOnFocus: false });
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const bypassDialogRef = useRef(false);
useKeyboardListener(["Shift"], (_, modifiers) => {
bypassDialogRef.current = modifiers.shift;
return false;
});
const handleDelete = useCallback(async () => { const handleDelete = useCallback(async () => {
await axios await axios
@ -241,13 +234,10 @@ function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
}); });
}, [config, onDelete, t]); }, [config, onDelete, t]);
const handleDeleteClick = useCallback(() => { const handleDeleteClick = useCallback((e: React.MouseEvent) => {
if (bypassDialogRef.current) { e.stopPropagation();
handleDelete(); setDeleteDialogOpen(true);
} else { }, []);
setDeleteDialogOpen(true);
}
}, [handleDelete]);
const coverImage = useMemo(() => { const coverImage = useMemo(() => {
if (!dataset) { if (!dataset) {
@ -304,7 +294,7 @@ function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
className="size-full" className="size-full"
src={`${baseUrl}clips/${config.name}/dataset/${coverImage?.name}/${coverImage?.img}`} src={`${baseUrl}clips/${config.name}/dataset/${coverImage?.name}/${coverImage?.img}`}
/> />
<ImageShadowOverlay /> <ImageShadowOverlay lowerClassName="h-[30%] z-0" />
<div className="absolute bottom-2 left-3 text-lg text-white smart-capitalize"> <div className="absolute bottom-2 left-3 text-lg text-white smart-capitalize">
{config.name} {config.name}
</div> </div>
@ -315,14 +305,13 @@ function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
<FiMoreVertical className="size-5 text-white" /> <FiMoreVertical className="size-5 text-white" />
</BlurredIconButton> </BlurredIconButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent
align="end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem onClick={handleDeleteClick}> <DropdownMenuItem onClick={handleDeleteClick}>
<LuTrash2 className="mr-2 size-4" /> <LuTrash2 className="mr-2 size-4" />
<span> <span>{t("button.delete", { ns: "common" })}</span>
{bypassDialogRef.current
? t("button.deleteNow", { ns: "common" })
: t("button.delete", { ns: "common" })}
</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>