Compare commits

...
6 changed files with 240 additions and 18 deletions
+6 -4
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
@@ -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,20 @@
"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.",
"motion": "Run on Motion",
"motionDesc": "Run classification when motion is detected within the configured crop.",
"interval": "Interval",
"intervalDesc": "Seconds between periodic classification runs. Leave empty to run only on motion.",
"intervalPlaceholder": "No interval",
"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",
"intervalInvalid": "Interval must be a whole number greater than 0"
}
}, },
"deleteDatasetImages": { "deleteDatasetImages": {
"title": "Delete Dataset Images", "title": "Delete Dataset Images",
@@ -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,25 @@ 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;
motion: boolean;
interval?: 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 +84,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 +110,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 +129,17 @@ export default function ClassificationModelEditDialog({
} else { } else {
// State model // State model
return z.object({ return z.object({
...sharedFields,
motion: z.boolean(),
interval: z.preprocess(
(val) =>
val === "" || val === null || val === undefined ? undefined : val,
z.coerce
.number({ message: t("edit.errors.intervalInvalid") })
.int(t("edit.errors.intervalInvalid"))
.positive(t("edit.errors.intervalInvalid"))
.optional(),
),
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 +166,18 @@ 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,
motion: model.state_config?.motion ?? false,
interval: model.state_config?.interval,
classes: [""], // Will be populated from dataset classes: [""], // Will be populated from dataset
} as StateFormData), } as StateFormData),
mode: "onChange", mode: "onChange",
@@ -151,6 +194,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 +203,10 @@ export default function ClassificationModelEditDialog({
} as ObjectFormData); } as ObjectFormData);
} else { } else {
form.reset({ form.reset({
enabled: model.enabled,
saveAttempts: model.save_attempts ?? defaultSaveAttempts,
motion: model.state_config?.motion ?? false,
interval: model.state_config?.interval,
classes: [""], classes: [""],
} as StateFormData); } as StateFormData);
} }
@@ -166,7 +215,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(() => {
@@ -233,6 +290,7 @@ export default function ClassificationModelEditDialog({
setIsSaving(true); setIsSaving(true);
try { try {
if (isObjectModel) { if (isObjectModel) {
// object model save
const objectData = data as ObjectFormData; const objectData = data as ObjectFormData;
// Update the config // Update the config
@@ -243,9 +301,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,
@@ -260,7 +319,34 @@ export default function ClassificationModelEditDialog({
position: "top-center", position: "top-center",
}); });
} else { } else {
// state model save
const stateData = data as StateFormData; const stateData = data as StateFormData;
const stateConfig: { motion: boolean; interval?: number | null } = {
motion: stateData.motion,
};
if (stateData.interval != null) {
stateConfig.interval = stateData.interval;
} else if (model.state_config?.interval != null) {
stateConfig.interval = null;
}
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,
state_config: stateConfig,
},
},
},
},
});
const newClasses = stateData.classes.filter( const newClasses = stateData.classes.filter(
(c) => c.trim().length > 0, (c) => c.trim().length > 0,
); );
@@ -307,11 +393,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 +445,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 +629,77 @@ export default function ClassificationModelEditDialog({
</div> </div>
)} )}
{isStateModel && (
<>
<FormField
control={form.control}
name="motion"
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.motion")}
</FormLabel>
<FormDescription className="text-xs">
{t("edit.motionDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="interval"
render={({ field }) => (
<FormItem>
<FormLabel className="text-primary-variant">
{t("edit.interval")}
</FormLabel>
<FormControl>
<Input
className="h-8"
inputMode="numeric"
placeholder={t("edit.intervalPlaceholder")}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormDescription className="text-xs">
{t("edit.intervalDesc")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<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"
+1
View File
@@ -370,6 +370,7 @@ export type CustomClassificationModelConfig = {
}; };
}; };
motion: boolean; motion: boolean;
interval?: number;
}; };
}; };
@@ -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>
@@ -348,6 +348,19 @@ function MotionPreviewClip({
} }
}, [clipStart, clipEnd, playbackRate, preview]); }, [clipStart, clipEnd, playbackRate, preview]);
useEffect(() => {
if (!videoRef.current || !preview || !videoPlaying) {
return;
}
if (isSafari || (isFirefox && isMobile)) {
// These browsers step frames manually; rebuild the interval at the new rate
resetPlayback();
} else {
videoRef.current.playbackRate = playbackRate;
}
}, [playbackRate, preview, videoPlaying, resetPlayback]);
const drawDimOverlay = useCallback(() => { const drawDimOverlay = useCallback(() => {
if (!dimOverlayCanvasRef.current) { if (!dimOverlayCanvasRef.current) {
return; return;