mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-25 21:29:01 +03:00
Implement Wizard for Creating Classification Models (#20622)
* Implement extraction of images for classification state models * Add object classification dataset preparation * Add first step wizard * Update i18n * Add state classification image selection step * Improve box handling * Add object selector * Improve object cropping implementation * Fix state classification selection * Finalize training and image selection step * Cleanup * Design optimizations * Cleanup mobile styling * Update no models screen * Cleanups and fixes * Fix bugs * Improve model training and creation process * Cleanup * Dynamically add metrics for new model * Add loading when hitting continue * Improve image selection mechanism * Remove unused translation keys * Adjust wording * Add retry button for image generation * Make no models view more specific * Adjust plus icon * Adjust form label * Start with correct type selected * Cleanup sizing and more font colors * Small tweaks * Add tips and more info * Cleanup dialog sizing * Add cursor rule for frontend * Cleanup * remove underline * Lazy loading
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
import { LuX, LuPlus, LuInfo, LuExternalLink } from "react-icons/lu";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
export type ModelType = "state" | "object";
|
||||
export type ObjectClassificationType = "sub_label" | "attribute";
|
||||
|
||||
export type Step1FormData = {
|
||||
modelName: string;
|
||||
modelType: ModelType;
|
||||
objectLabel?: string;
|
||||
objectType?: ObjectClassificationType;
|
||||
classes: string[];
|
||||
};
|
||||
|
||||
type Step1NameAndDefineProps = {
|
||||
initialData?: Partial<Step1FormData>;
|
||||
defaultModelType?: "state" | "object";
|
||||
onNext: (data: Step1FormData) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export default function Step1NameAndDefine({
|
||||
initialData,
|
||||
defaultModelType,
|
||||
onNext,
|
||||
onCancel,
|
||||
}: Step1NameAndDefineProps) {
|
||||
const { t } = useTranslation(["views/classificationModel"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
|
||||
const objectLabels = useMemo(() => {
|
||||
if (!config) return [];
|
||||
|
||||
const labels = new Set<string>();
|
||||
|
||||
Object.values(config.cameras).forEach((cameraConfig) => {
|
||||
if (!cameraConfig.enabled || !cameraConfig.enabled_in_config) {
|
||||
return;
|
||||
}
|
||||
|
||||
cameraConfig.objects.track.forEach((label) => {
|
||||
if (!config.model.all_attributes.includes(label)) {
|
||||
labels.add(label);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return [...labels].sort();
|
||||
}, [config]);
|
||||
|
||||
const step1FormData = z
|
||||
.object({
|
||||
modelName: z
|
||||
.string()
|
||||
.min(1, t("wizard.step1.errors.nameRequired"))
|
||||
.max(64, t("wizard.step1.errors.nameLength"))
|
||||
.refine((value) => !/^\d+$/.test(value), {
|
||||
message: t("wizard.step1.errors.nameOnlyNumbers"),
|
||||
}),
|
||||
modelType: z.enum(["state", "object"]),
|
||||
objectLabel: z.string().optional(),
|
||||
objectType: z.enum(["sub_label", "attribute"]).optional(),
|
||||
classes: z
|
||||
.array(z.string())
|
||||
.min(1, t("wizard.step1.errors.classRequired"))
|
||||
.refine(
|
||||
(classes) => {
|
||||
const nonEmpty = classes.filter((c) => c.trim().length > 0);
|
||||
return nonEmpty.length >= 1;
|
||||
},
|
||||
{ message: t("wizard.step1.errors.classRequired") },
|
||||
)
|
||||
.refine(
|
||||
(classes) => {
|
||||
const nonEmpty = classes.filter((c) => c.trim().length > 0);
|
||||
const unique = new Set(nonEmpty.map((c) => c.toLowerCase()));
|
||||
return unique.size === nonEmpty.length;
|
||||
},
|
||||
{ message: t("wizard.step1.errors.classesUnique") },
|
||||
),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// State models require at least 2 classes
|
||||
if (data.modelType === "state") {
|
||||
const nonEmpty = data.classes.filter((c) => c.trim().length > 0);
|
||||
return nonEmpty.length >= 2;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t("wizard.step1.errors.stateRequiresTwoClasses"),
|
||||
path: ["classes"],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.modelType === "object") {
|
||||
return data.objectLabel !== undefined && data.objectLabel !== "";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t("wizard.step1.errors.objectLabelRequired"),
|
||||
path: ["objectLabel"],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.modelType === "object") {
|
||||
return data.objectType !== undefined;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t("wizard.step1.errors.objectTypeRequired"),
|
||||
path: ["objectType"],
|
||||
},
|
||||
);
|
||||
|
||||
const form = useForm<z.infer<typeof step1FormData>>({
|
||||
resolver: zodResolver(step1FormData),
|
||||
defaultValues: {
|
||||
modelName: initialData?.modelName || "",
|
||||
modelType: initialData?.modelType || defaultModelType || "state",
|
||||
objectLabel: initialData?.objectLabel,
|
||||
objectType: initialData?.objectType || "sub_label",
|
||||
classes: initialData?.classes?.length ? initialData.classes : [""],
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const watchedClasses = form.watch("classes");
|
||||
const watchedModelType = form.watch("modelType");
|
||||
const watchedObjectType = form.watch("objectType");
|
||||
|
||||
const handleAddClass = () => {
|
||||
const currentClasses = form.getValues("classes");
|
||||
form.setValue("classes", [...currentClasses, ""], { shouldValidate: true });
|
||||
};
|
||||
|
||||
const handleRemoveClass = (index: number) => {
|
||||
const currentClasses = form.getValues("classes");
|
||||
const newClasses = currentClasses.filter((_, i) => i !== index);
|
||||
|
||||
// Ensure at least one field remains (even if empty)
|
||||
if (newClasses.length === 0) {
|
||||
form.setValue("classes", [""], { shouldValidate: true });
|
||||
} else {
|
||||
form.setValue("classes", newClasses, { shouldValidate: true });
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = (data: z.infer<typeof step1FormData>) => {
|
||||
// Filter out empty classes
|
||||
const filteredClasses = data.classes.filter((c) => c.trim().length > 0);
|
||||
onNext({
|
||||
...data,
|
||||
classes: filteredClasses,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="modelName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("wizard.step1.name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="h-8"
|
||||
placeholder={t("wizard.step1.namePlaceholder")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="modelType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("wizard.step1.type")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex flex-col gap-4 pt-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem
|
||||
className={
|
||||
watchedModelType === "state"
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
id="state"
|
||||
value="state"
|
||||
/>
|
||||
<Label className="cursor-pointer" htmlFor="state">
|
||||
{t("wizard.step1.typeState")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem
|
||||
className={
|
||||
watchedModelType === "object"
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
id="object"
|
||||
value="object"
|
||||
/>
|
||||
<Label className="cursor-pointer" htmlFor="object">
|
||||
{t("wizard.step1.typeObject")}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{watchedModelType === "object" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="objectLabel"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("wizard.step1.objectLabel")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"wizard.step1.objectLabelPlaceholder",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{objectLabels.map((label) => (
|
||||
<SelectItem
|
||||
key={label}
|
||||
value={label}
|
||||
className="cursor-pointer hover:bg-secondary-highlight"
|
||||
>
|
||||
{getTranslatedLabel(label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="objectType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("wizard.step1.classificationType")}
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 w-4 p-0"
|
||||
>
|
||||
<LuInfo className="size-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-sm">
|
||||
{t("wizard.step1.classificationTypeDesc")}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center text-primary">
|
||||
<a
|
||||
href={getLocaleDocUrl(
|
||||
"configuration/custom_classification/object_classification#classification-type",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline cursor-pointer"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex flex-col gap-4 pt-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem
|
||||
className={
|
||||
watchedObjectType === "sub_label"
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
id="sub_label"
|
||||
value="sub_label"
|
||||
/>
|
||||
<Label className="cursor-pointer" htmlFor="sub_label">
|
||||
{t("wizard.step1.classificationSubLabel")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem
|
||||
className={
|
||||
watchedObjectType === "attribute"
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
id="attribute"
|
||||
value="attribute"
|
||||
/>
|
||||
<Label className="cursor-pointer" htmlFor="attribute">
|
||||
{t("wizard.step1.classificationAttribute")}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("wizard.step1.classes")}
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
||||
<LuInfo className="size-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="pointer-events-auto w-80 text-xs">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-sm">
|
||||
{watchedModelType === "state"
|
||||
? t("wizard.step1.classesStateDesc")
|
||||
: t("wizard.step1.classesObjectDesc")}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center text-primary">
|
||||
<a
|
||||
href={getLocaleDocUrl(
|
||||
watchedModelType === "state"
|
||||
? "configuration/custom_classification/state_classification"
|
||||
: "configuration/custom_classification/object_classification",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline cursor-pointer"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
|
||||
onClick={handleAddClass}
|
||||
>
|
||||
<LuPlus />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{watchedClasses.map((_, index) => (
|
||||
<FormField
|
||||
key={index}
|
||||
control={form.control}
|
||||
name={`classes.${index}`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="h-8"
|
||||
placeholder={t("wizard.step1.classPlaceholder")}
|
||||
{...field}
|
||||
/>
|
||||
{watchedClasses.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => handleRemoveClass(index)}
|
||||
>
|
||||
<LuX className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{form.formState.errors.classes && (
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{form.formState.errors.classes.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button type="button" onClick={onCancel} className="sm:flex-1">
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(onSubmit)}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
disabled={!form.formState.isValid}
|
||||
>
|
||||
{t("button.continue", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user