add ability to edit enabled and save_attempts for classification models in the UI

This commit is contained in:
Josh Hawkins 2026-07-12 07:54:23 -05:00
parent 5f6043aa92
commit d14932c0af
4 changed files with 140 additions and 18 deletions

View File

@ -376,18 +376,20 @@ class EmbeddingMaintainer(threading.Thread):
logger.info(f"Disabled classification processor for model: {model_name}") logger.info(f"Disabled classification processor for model: {model_name}")
return return
# Check if processor already exists
for processor in self.realtime_processors: for processor in self.realtime_processors:
if isinstance( if (
isinstance(
processor, processor,
( (
CustomStateClassificationProcessor, CustomStateClassificationProcessor,
CustomObjectClassificationProcessor, CustomObjectClassificationProcessor,
), ),
)
and processor.model_config.name == model_name
): ):
if processor.model_config.name == model_name: processor.model_config = model_config
logger.debug( logger.debug(
f"Classification processor for model {model_name} already exists, skipping" f"Updated config for classification processor: {model_name}"
) )
return return

View File

@ -1,5 +1,6 @@
{ {
"documentTitle": "Classification Models - Frigate", "documentTitle": "Classification Models - Frigate",
"disabled": "Disabled",
"details": { "details": {
"scoreInfo": "Score represents the average classification confidence across all detections of this object.", "scoreInfo": "Score represents the average classification confidence across all detections of this object.",
"none": "None", "none": "None",
@ -64,7 +65,14 @@
"title": "Edit Classification Model", "title": "Edit Classification Model",
"descriptionState": "Edit the classes for this state classification model. Changes will require retraining the model.", "descriptionState": "Edit the classes for this state classification model. Changes will require retraining the model.",
"descriptionObject": "Edit the object type and classification type for this object classification model.", "descriptionObject": "Edit the object type and classification type for this object classification model.",
"stateClassesInfo": "Note: Changing state classes requires retraining the model with the updated classes." "enabled": "Enabled",
"enabledDesc": "Run this model. When disabled, it stops running and no longer classifies.",
"saveAttempts": "Save Attempts",
"saveAttemptsDesc": "Number of classification attempt images to keep for the recent classifications UI.",
"stateClassesInfo": "Model updated. Retrain the model for the class changes to take effect.",
"errors": {
"saveAttemptsInvalid": "Save attempts must be a whole number of 0 or greater"
}
}, },
"deleteDatasetImages": { "deleteDatasetImages": {
"title": "Delete Dataset Images", "title": "Delete Dataset Images",

View File

@ -9,6 +9,7 @@ import {
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@ -17,6 +18,7 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -50,14 +52,23 @@ type ClassificationModelEditDialogProps = {
type ObjectClassificationType = "sub_label" | "attribute"; type ObjectClassificationType = "sub_label" | "attribute";
type ObjectFormData = { type ObjectFormData = {
enabled: boolean;
saveAttempts: number;
objectLabel: string; objectLabel: string;
objectType: ObjectClassificationType; objectType: ObjectClassificationType;
}; };
type StateFormData = { type StateFormData = {
enabled: boolean;
saveAttempts: number;
classes: string[]; classes: string[];
}; };
const DEFAULT_SAVE_ATTEMPTS = {
object: 200,
state: 100,
} as const;
export default function ClassificationModelEditDialog({ export default function ClassificationModelEditDialog({
open, open,
model, model,
@ -71,6 +82,10 @@ export default function ClassificationModelEditDialog({
const isStateModel = model.state_config !== undefined; const isStateModel = model.state_config !== undefined;
const isObjectModel = model.object_config !== undefined; const isObjectModel = model.object_config !== undefined;
const defaultSaveAttempts = isObjectModel
? DEFAULT_SAVE_ATTEMPTS.object
: DEFAULT_SAVE_ATTEMPTS.state;
const objectLabels = useMemo(() => { const objectLabels = useMemo(() => {
if (!config) return []; if (!config) return [];
@ -93,8 +108,17 @@ export default function ClassificationModelEditDialog({
// Define form schema based on model type // Define form schema based on model type
const formSchema = useMemo(() => { const formSchema = useMemo(() => {
const sharedFields = {
enabled: z.boolean(),
saveAttempts: z.coerce
.number({ message: t("edit.errors.saveAttemptsInvalid") })
.int(t("edit.errors.saveAttemptsInvalid"))
.min(0, t("edit.errors.saveAttemptsInvalid")),
};
if (isObjectModel) { if (isObjectModel) {
return z.object({ return z.object({
...sharedFields,
objectLabel: z objectLabel: z
.string() .string()
.min(1, t("wizard.step1.errors.objectLabelRequired")), .min(1, t("wizard.step1.errors.objectLabelRequired")),
@ -103,6 +127,7 @@ export default function ClassificationModelEditDialog({
} else { } else {
// State model // State model
return z.object({ return z.object({
...sharedFields,
classes: z classes: z
.array(z.string()) .array(z.string())
.min(1, t("wizard.step1.errors.classRequired")) .min(1, t("wizard.step1.errors.classRequired"))
@ -129,12 +154,16 @@ export default function ClassificationModelEditDialog({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: isObjectModel defaultValues: isObjectModel
? ({ ? ({
enabled: model.enabled,
saveAttempts: model.save_attempts ?? defaultSaveAttempts,
objectLabel: model.object_config?.objects?.[0] || "", objectLabel: model.object_config?.objects?.[0] || "",
objectType: objectType:
(model.object_config (model.object_config
?.classification_type as ObjectClassificationType) || "sub_label", ?.classification_type as ObjectClassificationType) || "sub_label",
} as ObjectFormData) } as ObjectFormData)
: ({ : ({
enabled: model.enabled,
saveAttempts: model.save_attempts ?? defaultSaveAttempts,
classes: [""], // Will be populated from dataset classes: [""], // Will be populated from dataset
} as StateFormData), } as StateFormData),
mode: "onChange", mode: "onChange",
@ -151,6 +180,8 @@ export default function ClassificationModelEditDialog({
if (open) { if (open) {
if (isObjectModel) { if (isObjectModel) {
form.reset({ form.reset({
enabled: model.enabled,
saveAttempts: model.save_attempts ?? defaultSaveAttempts,
objectLabel: model.object_config?.objects?.[0] || "", objectLabel: model.object_config?.objects?.[0] || "",
objectType: objectType:
(model.object_config (model.object_config
@ -158,6 +189,8 @@ export default function ClassificationModelEditDialog({
} as ObjectFormData); } as ObjectFormData);
} else { } else {
form.reset({ form.reset({
enabled: model.enabled,
saveAttempts: model.save_attempts ?? defaultSaveAttempts,
classes: [""], classes: [""],
} as StateFormData); } as StateFormData);
} }
@ -166,7 +199,15 @@ export default function ClassificationModelEditDialog({
mutateDataset(); mutateDataset();
} }
} }
}, [open, isObjectModel, isStateModel, model, form, mutateDataset]); }, [
open,
isObjectModel,
isStateModel,
model,
form,
mutateDataset,
defaultSaveAttempts,
]);
// Update form with classes from dataset when loaded // Update form with classes from dataset when loaded
useEffect(() => { useEffect(() => {
@ -243,9 +284,10 @@ export default function ClassificationModelEditDialog({
classification: { classification: {
custom: { custom: {
[model.name]: { [model.name]: {
enabled: model.enabled, enabled: objectData.enabled,
name: model.name, name: model.name,
threshold: model.threshold, threshold: model.threshold,
save_attempts: objectData.saveAttempts,
object_config: { object_config: {
objects: [objectData.objectLabel], objects: [objectData.objectLabel],
classification_type: objectData.objectType, classification_type: objectData.objectType,
@ -261,6 +303,22 @@ export default function ClassificationModelEditDialog({
}); });
} else { } else {
const stateData = data as StateFormData; const stateData = data as StateFormData;
await axios.put("/config/set", {
requires_restart: 0,
update_topic: `config/classification/custom/${model.name}`,
config_data: {
classification: {
custom: {
[model.name]: {
enabled: stateData.enabled,
save_attempts: stateData.saveAttempts,
},
},
},
},
});
const newClasses = stateData.classes.filter( const newClasses = stateData.classes.filter(
(c) => c.trim().length > 0, (c) => c.trim().length > 0,
); );
@ -307,11 +365,11 @@ export default function ClassificationModelEditDialog({
if (renamePromises.length > 0) { if (renamePromises.length > 0) {
await Promise.all(renamePromises); await Promise.all(renamePromises);
await mutate(`classification/${model.name}/dataset`); await mutate(`classification/${model.name}/dataset`);
toast.success(t("toast.success.updatedModel"), { toast.success(t("edit.stateClassesInfo"), {
position: "top-center", position: "top-center",
}); });
} else { } else {
toast.info(t("edit.stateClassesInfo"), { toast.success(t("toast.success.updatedModel"), {
position: "top-center", position: "top-center",
}); });
} }
@ -359,6 +417,29 @@ export default function ClassificationModelEditDialog({
<div className="space-y-6"> <div className="space-y-6">
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between gap-4">
<div className="space-y-0.5">
<FormLabel className="text-primary-variant">
{t("edit.enabled")}
</FormLabel>
<FormDescription className="text-xs">
{t("edit.enabledDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{isObjectModel && ( {isObjectModel && (
<> <>
<FormField <FormField
@ -520,6 +601,25 @@ export default function ClassificationModelEditDialog({
</div> </div>
)} )}
<FormField
control={form.control}
name="saveAttempts"
render={({ field }) => (
<FormItem>
<FormLabel className="text-primary-variant">
{t("edit.saveAttempts")}
</FormLabel>
<FormControl>
<Input className="h-8" inputMode="numeric" {...field} />
</FormControl>
<FormDescription className="text-xs">
{t("edit.saveAttemptsDesc")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<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 <Button
type="button" type="button"

View File

@ -3,6 +3,7 @@ import ClassificationModelWizardDialog from "@/components/classification/Classif
import ClassificationModelEditDialog from "@/components/classification/ClassificationModelEditDialog"; import ClassificationModelEditDialog from "@/components/classification/ClassificationModelEditDialog";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { ImageShadowOverlay } from "@/components/overlay/ImageShadowOverlay"; import { ImageShadowOverlay } from "@/components/overlay/ImageShadowOverlay";
import { Badge } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import useOptimisticState from "@/hooks/use-optimistic-state"; import useOptimisticState from "@/hooks/use-optimistic-state";
@ -330,7 +331,10 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
{coverImage ? ( {coverImage ? (
<> <>
<img <img
className="size-full" className={cn(
"size-full",
!config.enabled && "opacity-50 grayscale",
)}
src={`${baseUrl}clips/${config.name}/dataset/${coverImage.name}/${coverImage.img}`} src={`${baseUrl}clips/${config.name}/dataset/${coverImage.name}/${coverImage.img}`}
/> />
<ImageShadowOverlay lowerClassName="h-[30%] z-0" /> <ImageShadowOverlay lowerClassName="h-[30%] z-0" />
@ -338,6 +342,14 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
) : ( ) : (
<Skeleton className="flex size-full items-center justify-center" /> <Skeleton className="flex size-full items-center justify-center" />
)} )}
{!config.enabled && (
<Badge
variant="secondary"
className="absolute right-2 top-2 z-40 text-primary-variant"
>
{t("disabled")}
</Badge>
)}
<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>