diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index 042c2722c2..a9b9b837b7 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -376,20 +376,22 @@ class EmbeddingMaintainer(threading.Thread): logger.info(f"Disabled classification processor for model: {model_name}") return - # Check if processor already exists for processor in self.realtime_processors: - if isinstance( - processor, - ( - CustomStateClassificationProcessor, - CustomObjectClassificationProcessor, - ), + if ( + isinstance( + processor, + ( + CustomStateClassificationProcessor, + CustomObjectClassificationProcessor, + ), + ) + and processor.model_config.name == model_name ): - if processor.model_config.name == model_name: - logger.debug( - f"Classification processor for model {model_name} already exists, skipping" - ) - return + processor.model_config = model_config + logger.debug( + f"Updated config for classification processor: {model_name}" + ) + return if model_config.state_config is not None: processor = CustomStateClassificationProcessor( diff --git a/web/public/locales/en/views/classificationModel.json b/web/public/locales/en/views/classificationModel.json index 3206ad0339..de0e2f920b 100644 --- a/web/public/locales/en/views/classificationModel.json +++ b/web/public/locales/en/views/classificationModel.json @@ -1,5 +1,6 @@ { "documentTitle": "Classification Models - Frigate", + "disabled": "Disabled", "details": { "scoreInfo": "Score represents the average classification confidence across all detections of this object.", "none": "None", @@ -64,7 +65,14 @@ "title": "Edit Classification 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.", - "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": { "title": "Delete Dataset Images", diff --git a/web/src/components/classification/ClassificationModelEditDialog.tsx b/web/src/components/classification/ClassificationModelEditDialog.tsx index ac49ee4db7..4db4fc0833 100644 --- a/web/src/components/classification/ClassificationModelEditDialog.tsx +++ b/web/src/components/classification/ClassificationModelEditDialog.tsx @@ -9,6 +9,7 @@ import { import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -17,6 +18,7 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, @@ -50,14 +52,23 @@ type ClassificationModelEditDialogProps = { type ObjectClassificationType = "sub_label" | "attribute"; type ObjectFormData = { + enabled: boolean; + saveAttempts: number; objectLabel: string; objectType: ObjectClassificationType; }; type StateFormData = { + enabled: boolean; + saveAttempts: number; classes: string[]; }; +const DEFAULT_SAVE_ATTEMPTS = { + object: 200, + state: 100, +} as const; + export default function ClassificationModelEditDialog({ open, model, @@ -71,6 +82,10 @@ export default function ClassificationModelEditDialog({ const isStateModel = model.state_config !== undefined; const isObjectModel = model.object_config !== undefined; + const defaultSaveAttempts = isObjectModel + ? DEFAULT_SAVE_ATTEMPTS.object + : DEFAULT_SAVE_ATTEMPTS.state; + const objectLabels = useMemo(() => { if (!config) return []; @@ -93,8 +108,17 @@ export default function ClassificationModelEditDialog({ // Define form schema based on model type 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) { return z.object({ + ...sharedFields, objectLabel: z .string() .min(1, t("wizard.step1.errors.objectLabelRequired")), @@ -103,6 +127,7 @@ export default function ClassificationModelEditDialog({ } else { // State model return z.object({ + ...sharedFields, classes: z .array(z.string()) .min(1, t("wizard.step1.errors.classRequired")) @@ -129,12 +154,16 @@ export default function ClassificationModelEditDialog({ resolver: zodResolver(formSchema), defaultValues: isObjectModel ? ({ + enabled: model.enabled, + saveAttempts: model.save_attempts ?? defaultSaveAttempts, objectLabel: model.object_config?.objects?.[0] || "", objectType: (model.object_config ?.classification_type as ObjectClassificationType) || "sub_label", } as ObjectFormData) : ({ + enabled: model.enabled, + saveAttempts: model.save_attempts ?? defaultSaveAttempts, classes: [""], // Will be populated from dataset } as StateFormData), mode: "onChange", @@ -151,6 +180,8 @@ export default function ClassificationModelEditDialog({ if (open) { if (isObjectModel) { form.reset({ + enabled: model.enabled, + saveAttempts: model.save_attempts ?? defaultSaveAttempts, objectLabel: model.object_config?.objects?.[0] || "", objectType: (model.object_config @@ -158,6 +189,8 @@ export default function ClassificationModelEditDialog({ } as ObjectFormData); } else { form.reset({ + enabled: model.enabled, + saveAttempts: model.save_attempts ?? defaultSaveAttempts, classes: [""], } as StateFormData); } @@ -166,7 +199,15 @@ export default function ClassificationModelEditDialog({ mutateDataset(); } } - }, [open, isObjectModel, isStateModel, model, form, mutateDataset]); + }, [ + open, + isObjectModel, + isStateModel, + model, + form, + mutateDataset, + defaultSaveAttempts, + ]); // Update form with classes from dataset when loaded useEffect(() => { @@ -243,9 +284,10 @@ export default function ClassificationModelEditDialog({ classification: { custom: { [model.name]: { - enabled: model.enabled, + enabled: objectData.enabled, name: model.name, threshold: model.threshold, + save_attempts: objectData.saveAttempts, object_config: { objects: [objectData.objectLabel], classification_type: objectData.objectType, @@ -261,6 +303,22 @@ export default function ClassificationModelEditDialog({ }); } else { 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( (c) => c.trim().length > 0, ); @@ -307,11 +365,11 @@ export default function ClassificationModelEditDialog({ if (renamePromises.length > 0) { await Promise.all(renamePromises); await mutate(`classification/${model.name}/dataset`); - toast.success(t("toast.success.updatedModel"), { + toast.success(t("edit.stateClassesInfo"), { position: "top-center", }); } else { - toast.info(t("edit.stateClassesInfo"), { + toast.success(t("toast.success.updatedModel"), { position: "top-center", }); } @@ -359,6 +417,29 @@ export default function ClassificationModelEditDialog({