mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-19 19:46:43 +03:00
Compare commits
No commits in common. "a510ea903675327dbb1d752403efbc066fe29272" and "b75122847668d6c500c95178e5fc767328c2945e" have entirely different histories.
a510ea9036
...
b751228476
@ -5,12 +5,6 @@ set -euxo pipefail
|
|||||||
SQLITE3_VERSION="3.46.1"
|
SQLITE3_VERSION="3.46.1"
|
||||||
PYSQLITE3_VERSION="0.5.3"
|
PYSQLITE3_VERSION="0.5.3"
|
||||||
|
|
||||||
# Install libsqlite3-dev if not present (needed for some base images like NVIDIA TensorRT)
|
|
||||||
if ! dpkg -l | grep -q libsqlite3-dev; then
|
|
||||||
echo "Installing libsqlite3-dev for compilation..."
|
|
||||||
apt-get update && apt-get install -y libsqlite3-dev && rm -rf /var/lib/apt/lists/*
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Fetch the pre-built sqlite amalgamation instead of building from source
|
# Fetch the pre-built sqlite amalgamation instead of building from source
|
||||||
if [[ ! -d "sqlite" ]]; then
|
if [[ ! -d "sqlite" ]]; then
|
||||||
mkdir sqlite
|
mkdir sqlite
|
||||||
|
|||||||
@ -112,7 +112,7 @@ RUN apt-get update \
|
|||||||
&& apt-get install -y protobuf-compiler libprotobuf-dev \
|
&& apt-get install -y protobuf-compiler libprotobuf-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
RUN --mount=type=bind,source=docker/tensorrt/requirements-models-arm64.txt,target=/requirements-tensorrt-models.txt \
|
RUN --mount=type=bind,source=docker/tensorrt/requirements-models-arm64.txt,target=/requirements-tensorrt-models.txt \
|
||||||
pip3 wheel --wheel-dir=/trt-model-wheels --no-deps -r /requirements-tensorrt-models.txt
|
pip3 wheel --wheel-dir=/trt-model-wheels -r /requirements-tensorrt-models.txt
|
||||||
|
|
||||||
FROM wget AS jetson-ffmpeg
|
FROM wget AS jetson-ffmpeg
|
||||||
ARG DEBIAN_FRONTEND
|
ARG DEBIAN_FRONTEND
|
||||||
@ -145,8 +145,7 @@ COPY --from=trt-wheels /etc/TENSORRT_VER /etc/TENSORRT_VER
|
|||||||
RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \
|
RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \
|
||||||
--mount=type=bind,from=trt-model-wheels,source=/trt-model-wheels,target=/deps/trt-model-wheels \
|
--mount=type=bind,from=trt-model-wheels,source=/trt-model-wheels,target=/deps/trt-model-wheels \
|
||||||
pip3 uninstall -y onnxruntime \
|
pip3 uninstall -y onnxruntime \
|
||||||
&& pip3 install -U /deps/trt-wheels/*.whl \
|
&& pip3 install -U /deps/trt-wheels/*.whl /deps/trt-model-wheels/*.whl \
|
||||||
&& pip3 install -U /deps/trt-model-wheels/*.whl \
|
|
||||||
&& ldconfig
|
&& ldconfig
|
||||||
|
|
||||||
WORKDIR /opt/frigate/
|
WORKDIR /opt/frigate/
|
||||||
|
|||||||
@ -466,7 +466,6 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
|||||||
now,
|
now,
|
||||||
self.labelmap[best_id],
|
self.labelmap[best_id],
|
||||||
score,
|
score,
|
||||||
max_files=200,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if score < self.model_config.threshold:
|
if score < self.model_config.threshold:
|
||||||
@ -530,7 +529,6 @@ def write_classification_attempt(
|
|||||||
timestamp: float,
|
timestamp: float,
|
||||||
label: str,
|
label: str,
|
||||||
score: float,
|
score: float,
|
||||||
max_files: int = 100,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if "-" in label:
|
if "-" in label:
|
||||||
label = label.replace("-", "_")
|
label = label.replace("-", "_")
|
||||||
@ -546,5 +544,5 @@ def write_classification_attempt(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# delete oldest face image if maximum is reached
|
# delete oldest face image if maximum is reached
|
||||||
if len(files) > max_files:
|
if len(files) > 100:
|
||||||
os.unlink(os.path.join(folder, files[-1]))
|
os.unlink(os.path.join(folder, files[-1]))
|
||||||
|
|||||||
@ -10,27 +10,23 @@
|
|||||||
"deleteImages": "Delete Images",
|
"deleteImages": "Delete Images",
|
||||||
"trainModel": "Train Model",
|
"trainModel": "Train Model",
|
||||||
"addClassification": "Add Classification",
|
"addClassification": "Add Classification",
|
||||||
"deleteModels": "Delete Models",
|
"deleteModels": "Delete Models"
|
||||||
"editModel": "Edit Model"
|
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"success": {
|
"success": {
|
||||||
"deletedCategory": "Deleted Class",
|
"deletedCategory": "Deleted Class",
|
||||||
"deletedImage": "Deleted Images",
|
"deletedImage": "Deleted Images",
|
||||||
"deletedModel_one": "Successfully deleted {{count}} model",
|
"deletedModel": "Successfully deleted {{count}} model(s)",
|
||||||
"deletedModel_other": "Successfully deleted {{count}} models",
|
|
||||||
"categorizedImage": "Successfully Classified Image",
|
"categorizedImage": "Successfully Classified Image",
|
||||||
"trainedModel": "Successfully trained model.",
|
"trainedModel": "Successfully trained model.",
|
||||||
"trainingModel": "Successfully started model training.",
|
"trainingModel": "Successfully started model training."
|
||||||
"updatedModel": "Successfully updated model configuration"
|
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"deleteImageFailed": "Failed to delete: {{errorMessage}}",
|
"deleteImageFailed": "Failed to delete: {{errorMessage}}",
|
||||||
"deleteCategoryFailed": "Failed to delete class: {{errorMessage}}",
|
"deleteCategoryFailed": "Failed to delete class: {{errorMessage}}",
|
||||||
"deleteModelFailed": "Failed to delete model: {{errorMessage}}",
|
"deleteModelFailed": "Failed to delete model: {{errorMessage}}",
|
||||||
"categorizeFailed": "Failed to categorize image: {{errorMessage}}",
|
"categorizeFailed": "Failed to categorize image: {{errorMessage}}",
|
||||||
"trainingFailed": "Failed to start model training: {{errorMessage}}",
|
"trainingFailed": "Failed to start model training: {{errorMessage}}"
|
||||||
"updateModelFailed": "Failed to update model: {{errorMessage}}"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteCategory": {
|
"deleteCategory": {
|
||||||
@ -42,12 +38,6 @@
|
|||||||
"single": "Are you sure you want to delete {{name}}? This will permanently delete all associated data including images and training data. This action cannot be undone.",
|
"single": "Are you sure you want to delete {{name}}? This will permanently delete all associated data including images and training data. This action cannot be undone.",
|
||||||
"desc": "Are you sure you want to delete {{count}} model(s)? This will permanently delete all associated data including images and training data. This action cannot be undone."
|
"desc": "Are you sure you want to delete {{count}} model(s)? This will permanently delete all associated data including images and training data. This action cannot be undone."
|
||||||
},
|
},
|
||||||
"edit": {
|
|
||||||
"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."
|
|
||||||
},
|
|
||||||
"deleteDatasetImages": {
|
"deleteDatasetImages": {
|
||||||
"title": "Delete Dataset Images",
|
"title": "Delete Dataset Images",
|
||||||
"desc": "Are you sure you want to delete {{count}} images from {{dataset}}? This action cannot be undone and will require re-training the model."
|
"desc": "Are you sure you want to delete {{count}} images from {{dataset}}? This action cannot be undone and will require re-training the model."
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
} from "@/types/classification";
|
} from "@/types/classification";
|
||||||
import { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
import { forwardRef, useMemo, useRef, useState } from "react";
|
import { forwardRef, useMemo, useRef, useState } from "react";
|
||||||
import { isDesktop, isMobile, isMobileOnly } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import TimeAgo from "../dynamic/TimeAgo";
|
import TimeAgo from "../dynamic/TimeAgo";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
@ -264,8 +264,8 @@ export function GroupedClassificationCard({
|
|||||||
|
|
||||||
const Overlay = isDesktop ? Dialog : MobilePage;
|
const Overlay = isDesktop ? Dialog : MobilePage;
|
||||||
const Trigger = isDesktop ? DialogTrigger : MobilePageTrigger;
|
const Trigger = isDesktop ? DialogTrigger : MobilePageTrigger;
|
||||||
const Content = isDesktop ? DialogContent : MobilePageContent;
|
|
||||||
const Header = isDesktop ? DialogHeader : MobilePageHeader;
|
const Header = isDesktop ? DialogHeader : MobilePageHeader;
|
||||||
|
const Content = isDesktop ? DialogContent : MobilePageContent;
|
||||||
const ContentTitle = isDesktop ? DialogTitle : MobilePageTitle;
|
const ContentTitle = isDesktop ? DialogTitle : MobilePageTitle;
|
||||||
const ContentDescription = isDesktop
|
const ContentDescription = isDesktop
|
||||||
? DialogDescription
|
? DialogDescription
|
||||||
@ -298,9 +298,9 @@ export function GroupedClassificationCard({
|
|||||||
<Trigger asChild></Trigger>
|
<Trigger asChild></Trigger>
|
||||||
<Content
|
<Content
|
||||||
className={cn(
|
className={cn(
|
||||||
"scrollbar-container",
|
"",
|
||||||
isDesktop && "min-w-[50%] max-w-[65%]",
|
isDesktop && "min-w-[50%] max-w-[65%]",
|
||||||
isMobile && "overflow-y-auto",
|
isMobile && "flex flex-col",
|
||||||
)}
|
)}
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
@ -308,16 +308,16 @@ export function GroupedClassificationCard({
|
|||||||
<Header
|
<Header
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-2 flex flex-row items-center gap-4",
|
"mx-2 flex flex-row items-center gap-4",
|
||||||
isMobileOnly && "top-0 mx-4",
|
isMobile && "flex-shrink-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div>
|
||||||
className={cn(
|
<ContentTitle
|
||||||
"",
|
className={cn(
|
||||||
isMobile && "flex flex-col items-center justify-center",
|
"flex items-center gap-2 font-normal capitalize",
|
||||||
)}
|
isMobile && "px-2",
|
||||||
>
|
)}
|
||||||
<ContentTitle className="flex items-center gap-2 font-normal capitalize">
|
>
|
||||||
{event?.sub_label && event.sub_label !== "none"
|
{event?.sub_label && event.sub_label !== "none"
|
||||||
? event.sub_label
|
? event.sub_label
|
||||||
: t(noClassificationLabel)}
|
: t(noClassificationLabel)}
|
||||||
@ -390,7 +390,7 @@ export function GroupedClassificationCard({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"grid w-full auto-rows-min grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-6 2xl:grid-cols-8",
|
"grid w-full auto-rows-min grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-6 2xl:grid-cols-8",
|
||||||
isDesktop && "p-2",
|
isDesktop && "p-2",
|
||||||
isMobile && "px-4 pb-4",
|
isMobile && "scrollbar-container flex-1 overflow-y-auto",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{group.map((data: ClassificationItemData) => (
|
{group.map((data: ClassificationItemData) => (
|
||||||
|
|||||||
@ -38,7 +38,6 @@ import { Button, buttonVariants } from "../ui/button";
|
|||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LuCircle } from "react-icons/lu";
|
import { LuCircle } from "react-icons/lu";
|
||||||
import { MdAutoAwesome } from "react-icons/md";
|
|
||||||
|
|
||||||
type ReviewCardProps = {
|
type ReviewCardProps = {
|
||||||
event: ReviewSegment;
|
event: ReviewSegment;
|
||||||
@ -165,33 +164,29 @@ export default function ReviewCard({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-evenly gap-1">
|
||||||
<LuCircle
|
<>
|
||||||
className={cn(
|
<LuCircle
|
||||||
"size-2",
|
className={cn(
|
||||||
event.severity == "alert"
|
"size-2",
|
||||||
? "fill-severity_alert text-severity_alert"
|
event.severity == "alert"
|
||||||
: "fill-severity_detection text-severity_detection",
|
? "fill-severity_alert text-severity_alert"
|
||||||
)}
|
: "fill-severity_detection text-severity_detection",
|
||||||
/>
|
)}
|
||||||
<div className="flex items-center gap-1">
|
/>
|
||||||
{event.data.objects.map((object, idx) => (
|
{event.data.objects.map((object) => {
|
||||||
<div
|
return getIconForLabel(
|
||||||
key={`${object}-${idx}`}
|
object,
|
||||||
className="rounded-full bg-muted-foreground p-1"
|
"size-3 text-primary dark:text-white",
|
||||||
>
|
);
|
||||||
{getIconForLabel(object, "size-3 text-white")}
|
})}
|
||||||
</div>
|
{event.data.audio.map((audio) => {
|
||||||
))}
|
return getIconForLabel(
|
||||||
{event.data.audio.map((audio, idx) => (
|
audio,
|
||||||
<div
|
"size-3 text-primary dark:text-white",
|
||||||
key={`${audio}-${idx}`}
|
);
|
||||||
className="rounded-full bg-muted-foreground p-1"
|
})}
|
||||||
>
|
</>
|
||||||
{getIconForLabel(audio, "size-3 text-white")}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="font-extra-light text-xs">{formattedDate}</div>
|
<div className="font-extra-light text-xs">{formattedDate}</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@ -218,14 +213,6 @@ export default function ReviewCard({
|
|||||||
dense
|
dense
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{event.data.metadata?.title && (
|
|
||||||
<div className="flex items-center gap-1.5 rounded bg-secondary/50">
|
|
||||||
<MdAutoAwesome className="size-3 shrink-0 text-primary" />
|
|
||||||
<span className="truncate text-xs text-primary">
|
|
||||||
{event.data.metadata.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,477 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
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 {
|
|
||||||
CustomClassificationModelConfig,
|
|
||||||
FrigateConfig,
|
|
||||||
} from "@/types/frigateConfig";
|
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import axios from "axios";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { LuPlus, LuX } from "react-icons/lu";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import useSWR from "swr";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
type ClassificationModelEditDialogProps = {
|
|
||||||
open: boolean;
|
|
||||||
model: CustomClassificationModelConfig;
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ObjectClassificationType = "sub_label" | "attribute";
|
|
||||||
|
|
||||||
type ObjectFormData = {
|
|
||||||
objectLabel: string;
|
|
||||||
objectType: ObjectClassificationType;
|
|
||||||
};
|
|
||||||
|
|
||||||
type StateFormData = {
|
|
||||||
classes: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ClassificationModelEditDialog({
|
|
||||||
open,
|
|
||||||
model,
|
|
||||||
onClose,
|
|
||||||
onSuccess,
|
|
||||||
}: ClassificationModelEditDialogProps) {
|
|
||||||
const { t } = useTranslation(["views/classificationModel"]);
|
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
|
|
||||||
const isStateModel = model.state_config !== undefined;
|
|
||||||
const isObjectModel = model.object_config !== undefined;
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
// Define form schema based on model type
|
|
||||||
const formSchema = useMemo(() => {
|
|
||||||
if (isObjectModel) {
|
|
||||||
return z.object({
|
|
||||||
objectLabel: z
|
|
||||||
.string()
|
|
||||||
.min(1, t("wizard.step1.errors.objectLabelRequired")),
|
|
||||||
objectType: z.enum(["sub_label", "attribute"]),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// State model
|
|
||||||
return z.object({
|
|
||||||
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 >= 2;
|
|
||||||
},
|
|
||||||
{ message: t("wizard.step1.errors.stateRequiresTwoClasses") },
|
|
||||||
)
|
|
||||||
.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") },
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [isObjectModel, t]);
|
|
||||||
|
|
||||||
const form = useForm<ObjectFormData | StateFormData>({
|
|
||||||
resolver: zodResolver(formSchema),
|
|
||||||
defaultValues: isObjectModel
|
|
||||||
? ({
|
|
||||||
objectLabel: model.object_config?.objects?.[0] || "",
|
|
||||||
objectType:
|
|
||||||
(model.object_config
|
|
||||||
?.classification_type as ObjectClassificationType) || "sub_label",
|
|
||||||
} as ObjectFormData)
|
|
||||||
: ({
|
|
||||||
classes: [""], // Will be populated from dataset
|
|
||||||
} as StateFormData),
|
|
||||||
mode: "onChange",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch dataset to get current classes for state models
|
|
||||||
const { data: dataset } = useSWR<{
|
|
||||||
[id: string]: string[];
|
|
||||||
}>(isStateModel ? `classification/${model.name}/dataset` : null, {
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update form with classes from dataset when loaded
|
|
||||||
useEffect(() => {
|
|
||||||
if (isStateModel && dataset) {
|
|
||||||
const classes = Object.keys(dataset).filter((key) => key !== "none");
|
|
||||||
if (classes.length > 0) {
|
|
||||||
(form as ReturnType<typeof useForm<StateFormData>>).setValue(
|
|
||||||
"classes",
|
|
||||||
classes,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [dataset, isStateModel, form]);
|
|
||||||
|
|
||||||
const watchedClasses = isStateModel
|
|
||||||
? (form as ReturnType<typeof useForm<StateFormData>>).watch("classes")
|
|
||||||
: undefined;
|
|
||||||
const watchedObjectType = isObjectModel
|
|
||||||
? (form as ReturnType<typeof useForm<ObjectFormData>>).watch("objectType")
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const handleAddClass = useCallback(() => {
|
|
||||||
const currentClasses = (
|
|
||||||
form as ReturnType<typeof useForm<StateFormData>>
|
|
||||||
).getValues("classes");
|
|
||||||
(form as ReturnType<typeof useForm<StateFormData>>).setValue(
|
|
||||||
"classes",
|
|
||||||
[...currentClasses, ""],
|
|
||||||
{
|
|
||||||
shouldValidate: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}, [form]);
|
|
||||||
|
|
||||||
const handleRemoveClass = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
const currentClasses = (
|
|
||||||
form as ReturnType<typeof useForm<StateFormData>>
|
|
||||||
).getValues("classes");
|
|
||||||
const newClasses = currentClasses.filter((_, i) => i !== index);
|
|
||||||
|
|
||||||
// Ensure at least one field remains (even if empty)
|
|
||||||
if (newClasses.length === 0) {
|
|
||||||
(form as ReturnType<typeof useForm<StateFormData>>).setValue(
|
|
||||||
"classes",
|
|
||||||
[""],
|
|
||||||
{ shouldValidate: true },
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
(form as ReturnType<typeof useForm<StateFormData>>).setValue(
|
|
||||||
"classes",
|
|
||||||
newClasses,
|
|
||||||
{ shouldValidate: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[form],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onSubmit = useCallback(
|
|
||||||
async (data: ObjectFormData | StateFormData) => {
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
if (isObjectModel) {
|
|
||||||
const objectData = data as ObjectFormData;
|
|
||||||
|
|
||||||
// Update the config
|
|
||||||
await axios.put("/config/set", {
|
|
||||||
requires_restart: 0,
|
|
||||||
update_topic: `config/classification/custom/${model.name}`,
|
|
||||||
config_data: {
|
|
||||||
classification: {
|
|
||||||
custom: {
|
|
||||||
[model.name]: {
|
|
||||||
enabled: model.enabled,
|
|
||||||
name: model.name,
|
|
||||||
threshold: model.threshold,
|
|
||||||
object_config: {
|
|
||||||
objects: [objectData.objectLabel],
|
|
||||||
classification_type: objectData.objectType,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.success(t("toast.success.updatedModel"), {
|
|
||||||
position: "top-center",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// State model - update classes
|
|
||||||
// Note: For state models, updating classes requires renaming categories
|
|
||||||
// which is handled through the dataset API, not the config API
|
|
||||||
// We'll need to implement this by calling the rename endpoint for each class
|
|
||||||
// For now, we just show a message that this requires retraining
|
|
||||||
|
|
||||||
toast.info(t("edit.stateClassesInfo"), {
|
|
||||||
position: "top-center",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onSuccess();
|
|
||||||
onClose();
|
|
||||||
} catch (err) {
|
|
||||||
const error = err as {
|
|
||||||
response?: { data?: { message?: string; detail?: string } };
|
|
||||||
};
|
|
||||||
const errorMessage =
|
|
||||||
error.response?.data?.message ||
|
|
||||||
error.response?.data?.detail ||
|
|
||||||
"Unknown error";
|
|
||||||
toast.error(t("toast.error.updateModelFailed", { errorMessage }), {
|
|
||||||
position: "top-center",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isObjectModel, model, t, onSuccess, onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
|
||||||
form.reset();
|
|
||||||
onClose();
|
|
||||||
}, [form, onClose]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(open) => !open && handleCancel()}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t("edit.title")}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{isStateModel
|
|
||||||
? t("edit.descriptionState")
|
|
||||||
: t("edit.descriptionObject")}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
{isObjectModel && (
|
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
<FormLabel className="text-primary-variant">
|
|
||||||
{t("wizard.step1.classificationType")}
|
|
||||||
</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={
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isStateModel && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<FormLabel className="text-primary-variant">
|
|
||||||
{t("wizard.step1.states")}
|
|
||||||
</FormLabel>
|
|
||||||
<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((_: string, index: number) => (
|
|
||||||
<FormField
|
|
||||||
key={index}
|
|
||||||
control={
|
|
||||||
(form as ReturnType<typeof useForm<StateFormData>>)
|
|
||||||
.control
|
|
||||||
}
|
|
||||||
name={`classes.${index}` as const}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
className="text-md h-8"
|
|
||||||
placeholder={t(
|
|
||||||
"wizard.step1.classPlaceholder",
|
|
||||||
)}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
{watchedClasses &&
|
|
||||||
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>
|
|
||||||
{isStateModel &&
|
|
||||||
"classes" in form.formState.errors &&
|
|
||||||
form.formState.errors.classes && (
|
|
||||||
<p className="text-sm font-medium text-destructive">
|
|
||||||
{form.formState.errors.classes.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCancel}
|
|
||||||
className="sm:flex-1"
|
|
||||||
disabled={isSaving}
|
|
||||||
>
|
|
||||||
{t("button.cancel", { ns: "common" })}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="select"
|
|
||||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
|
||||||
disabled={!form.formState.isValid || isSaving}
|
|
||||||
>
|
|
||||||
{isSaving
|
|
||||||
? t("button.saving", { ns: "common" })
|
|
||||||
: t("button.save", { ns: "common" })}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -25,7 +25,6 @@ type NameAndIdFieldsProps<T extends FieldValues = FieldValues> = {
|
|||||||
processId?: (name: string) => string;
|
processId?: (name: string) => string;
|
||||||
placeholderName?: string;
|
placeholderName?: string;
|
||||||
placeholderId?: string;
|
placeholderId?: string;
|
||||||
idVisible?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||||
@ -40,11 +39,10 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
|||||||
processId,
|
processId,
|
||||||
placeholderName,
|
placeholderName,
|
||||||
placeholderId,
|
placeholderId,
|
||||||
idVisible,
|
|
||||||
}: NameAndIdFieldsProps<T>) {
|
}: NameAndIdFieldsProps<T>) {
|
||||||
const { t } = useTranslation(["common"]);
|
const { t } = useTranslation(["common"]);
|
||||||
const { watch, setValue, trigger, formState } = useFormContext<T>();
|
const { watch, setValue, trigger, formState } = useFormContext<T>();
|
||||||
const [isIdVisible, setIsIdVisible] = useState(idVisible ?? false);
|
const [isIdVisible, setIsIdVisible] = useState(false);
|
||||||
const hasUserTypedRef = useRef(false);
|
const hasUserTypedRef = useRef(false);
|
||||||
|
|
||||||
const defaultProcessId = (name: string) => {
|
const defaultProcessId = (name: string) => {
|
||||||
|
|||||||
@ -258,7 +258,6 @@ export default function CreateTriggerDialog({
|
|||||||
nameLabel={t("triggers.dialog.form.name.title")}
|
nameLabel={t("triggers.dialog.form.name.title")}
|
||||||
nameDescription={t("triggers.dialog.form.name.description")}
|
nameDescription={t("triggers.dialog.form.name.description")}
|
||||||
placeholderName={t("triggers.dialog.form.name.placeholder")}
|
placeholderName={t("triggers.dialog.form.name.placeholder")}
|
||||||
idVisible={!!trigger}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@ -385,7 +385,7 @@ export default function Step1NameCamera({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
className="text-md h-8"
|
className="h-8"
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"cameraWizard.step1.cameraNamePlaceholder",
|
"cameraWizard.step1.cameraNamePlaceholder",
|
||||||
)}
|
)}
|
||||||
@ -475,7 +475,7 @@ export default function Step1NameCamera({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
className="text-md h-8"
|
className="h-8"
|
||||||
placeholder="192.168.1.100"
|
placeholder="192.168.1.100"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
@ -495,7 +495,7 @@ export default function Step1NameCamera({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
className="text-md h-8"
|
className="h-8"
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"cameraWizard.step1.usernamePlaceholder",
|
"cameraWizard.step1.usernamePlaceholder",
|
||||||
)}
|
)}
|
||||||
@ -518,7 +518,7 @@ export default function Step1NameCamera({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
className="text-md h-8 pr-10"
|
className="h-8 pr-10"
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"cameraWizard.step1.passwordPlaceholder",
|
"cameraWizard.step1.passwordPlaceholder",
|
||||||
@ -558,7 +558,7 @@ export default function Step1NameCamera({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
className="text-md h-8"
|
className="h-8"
|
||||||
placeholder="rtsp://username:password@host:port/path"
|
placeholder="rtsp://username:password@host:port/path"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -22,7 +22,6 @@ import {
|
|||||||
LuChevronRight,
|
LuChevronRight,
|
||||||
LuSettings,
|
LuSettings,
|
||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
import { MdAutoAwesome } from "react-icons/md";
|
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
import EventMenu from "@/components/timeline/EventMenu";
|
import EventMenu from "@/components/timeline/EventMenu";
|
||||||
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
|
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
|
||||||
@ -411,9 +410,8 @@ function ReviewGroup({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
{review.data.metadata?.title && (
|
{review.data.metadata?.title && (
|
||||||
<div className="mb-1 flex items-center gap-1 text-sm text-primary-variant">
|
<div className="mb-1 text-sm text-primary-variant">
|
||||||
<MdAutoAwesome className="size-3 shrink-0" />
|
{review.data.metadata.title}
|
||||||
<span className="truncate">{review.data.metadata.title}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-row items-center gap-1.5">
|
<div className="flex flex-row items-center gap-1.5">
|
||||||
@ -460,7 +458,6 @@ function ReviewGroup({
|
|||||||
<EventList
|
<EventList
|
||||||
key={event.id}
|
key={event.id}
|
||||||
event={event}
|
event={event}
|
||||||
review={review}
|
|
||||||
effectiveTime={effectiveTime}
|
effectiveTime={effectiveTime}
|
||||||
annotationOffset={annotationOffset}
|
annotationOffset={annotationOffset}
|
||||||
onSeek={onSeek}
|
onSeek={onSeek}
|
||||||
@ -495,7 +492,6 @@ function ReviewGroup({
|
|||||||
|
|
||||||
type EventListProps = {
|
type EventListProps = {
|
||||||
event: Event;
|
event: Event;
|
||||||
review: ReviewSegment;
|
|
||||||
effectiveTime?: number;
|
effectiveTime?: number;
|
||||||
annotationOffset: number;
|
annotationOffset: number;
|
||||||
onSeek: (ts: number, play?: boolean) => void;
|
onSeek: (ts: number, play?: boolean) => void;
|
||||||
@ -503,7 +499,6 @@ type EventListProps = {
|
|||||||
};
|
};
|
||||||
function EventList({
|
function EventList({
|
||||||
event,
|
event,
|
||||||
review,
|
|
||||||
effectiveTime,
|
effectiveTime,
|
||||||
annotationOffset,
|
annotationOffset,
|
||||||
onSeek,
|
onSeek,
|
||||||
@ -622,7 +617,6 @@ function EventList({
|
|||||||
|
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<ObjectTimeline
|
<ObjectTimeline
|
||||||
review={review}
|
|
||||||
eventId={event.id}
|
eventId={event.id}
|
||||||
onSeek={handleTimelineClick}
|
onSeek={handleTimelineClick}
|
||||||
effectiveTime={effectiveTime}
|
effectiveTime={effectiveTime}
|
||||||
@ -771,7 +765,6 @@ function LifecycleItem({
|
|||||||
|
|
||||||
// Fetch and render timeline entries for a single event id on demand.
|
// Fetch and render timeline entries for a single event id on demand.
|
||||||
function ObjectTimeline({
|
function ObjectTimeline({
|
||||||
review,
|
|
||||||
eventId,
|
eventId,
|
||||||
onSeek,
|
onSeek,
|
||||||
effectiveTime,
|
effectiveTime,
|
||||||
@ -779,7 +772,6 @@ function ObjectTimeline({
|
|||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
}: {
|
}: {
|
||||||
review: ReviewSegment;
|
|
||||||
eventId: string;
|
eventId: string;
|
||||||
onSeek: (ts: number, play?: boolean) => void;
|
onSeek: (ts: number, play?: boolean) => void;
|
||||||
effectiveTime?: number;
|
effectiveTime?: number;
|
||||||
@ -788,27 +780,13 @@ function ObjectTimeline({
|
|||||||
endTime?: number;
|
endTime?: number;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("views/events");
|
const { t } = useTranslation("views/events");
|
||||||
const { data: fullTimeline, isValidating } = useSWR<
|
const { data: timeline, isValidating } = useSWR<TrackingDetailsSequence[]>([
|
||||||
TrackingDetailsSequence[]
|
|
||||||
>([
|
|
||||||
"timeline",
|
"timeline",
|
||||||
{
|
{
|
||||||
source_id: eventId,
|
source_id: eventId,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const timeline = useMemo(() => {
|
|
||||||
if (!fullTimeline) {
|
|
||||||
return fullTimeline;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fullTimeline.filter(
|
|
||||||
(t) =>
|
|
||||||
t.timestamp >= review.start_time &&
|
|
||||||
(review.end_time == undefined || t.timestamp <= review.end_time),
|
|
||||||
);
|
|
||||||
}, [fullTimeline, review]);
|
|
||||||
|
|
||||||
if (isValidating && (!timeline || timeline.length === 0)) {
|
if (isValidating && (!timeline || timeline.length === 0)) {
|
||||||
return <ActivityIndicator className="ml-2 size-3" />;
|
return <ActivityIndicator className="ml-2 size-3" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useApiHost } from "@/api";
|
||||||
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
||||||
import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils";
|
import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils";
|
||||||
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
||||||
@ -17,7 +18,6 @@ import { HoverCardPortal } from "@radix-ui/react-hover-card";
|
|||||||
import scrollIntoView from "scroll-into-view-if-needed";
|
import scrollIntoView from "scroll-into-view-if-needed";
|
||||||
import { MinimapBounds, Tick, Timestamp } from "./segment-metadata";
|
import { MinimapBounds, Tick, Timestamp } from "./segment-metadata";
|
||||||
import useTapUtils from "@/hooks/use-tap-utils";
|
import useTapUtils from "@/hooks/use-tap-utils";
|
||||||
import ReviewCard from "../card/ReviewCard";
|
|
||||||
|
|
||||||
type EventSegmentProps = {
|
type EventSegmentProps = {
|
||||||
events: ReviewSegment[];
|
events: ReviewSegment[];
|
||||||
@ -54,7 +54,7 @@ export function EventSegment({
|
|||||||
displaySeverityType,
|
displaySeverityType,
|
||||||
shouldShowRoundedCorners,
|
shouldShowRoundedCorners,
|
||||||
getEventStart,
|
getEventStart,
|
||||||
getEvent,
|
getEventThumbnail,
|
||||||
} = useEventSegmentUtils(segmentDuration, events, severityType);
|
} = useEventSegmentUtils(segmentDuration, events, severityType);
|
||||||
|
|
||||||
const { alignStartDateToTimeline, alignEndDateToTimeline } = useTimelineUtils(
|
const { alignStartDateToTimeline, alignEndDateToTimeline } = useTimelineUtils(
|
||||||
@ -87,11 +87,13 @@ export function EventSegment({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [getEventStart, segmentTime]);
|
}, [getEventStart, segmentTime]);
|
||||||
|
|
||||||
|
const apiHost = useApiHost();
|
||||||
|
|
||||||
const { handleTouchStart } = useTapUtils();
|
const { handleTouchStart } = useTapUtils();
|
||||||
|
|
||||||
const segmentEvent = useMemo(() => {
|
const eventThumbnail = useMemo(() => {
|
||||||
return getEvent(segmentTime);
|
return getEventThumbnail(segmentTime);
|
||||||
}, [getEvent, segmentTime]);
|
}, [getEventThumbnail, segmentTime]);
|
||||||
|
|
||||||
const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]);
|
const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]);
|
||||||
const segmentKey = useMemo(
|
const segmentKey = useMemo(
|
||||||
@ -250,7 +252,10 @@ export function EventSegment({
|
|||||||
className="w-[250px] rounded-lg p-2 md:rounded-2xl"
|
className="w-[250px] rounded-lg p-2 md:rounded-2xl"
|
||||||
side="left"
|
side="left"
|
||||||
>
|
>
|
||||||
{segmentEvent && <ReviewCard event={segmentEvent} />}
|
<img
|
||||||
|
className="rounded-lg"
|
||||||
|
src={`${apiHost}${eventThumbnail.replace("/media/frigate/", "")}`}
|
||||||
|
/>
|
||||||
</HoverCardContent>
|
</HoverCardContent>
|
||||||
</HoverCardPortal>
|
</HoverCardPortal>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
|
|||||||
@ -101,7 +101,7 @@ export default function Step1NameAndType({
|
|||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
mode: "onBlur",
|
mode: "onChange",
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
name: initialData?.name ?? trigger?.name ?? "",
|
name: initialData?.name ?? trigger?.name ?? "",
|
||||||
|
|||||||
@ -191,8 +191,8 @@ export const useEventSegmentUtils = (
|
|||||||
[events, getSegmentStart, getSegmentEnd, severityType],
|
[events, getSegmentStart, getSegmentEnd, severityType],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getEvent = useCallback(
|
const getEventThumbnail = useCallback(
|
||||||
(time: number): ReviewSegment | undefined => {
|
(time: number): string => {
|
||||||
const matchingEvent = events.find((event) => {
|
const matchingEvent = events.find((event) => {
|
||||||
return (
|
return (
|
||||||
time >= getSegmentStart(event.start_time) &&
|
time >= getSegmentStart(event.start_time) &&
|
||||||
@ -201,7 +201,7 @@ export const useEventSegmentUtils = (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return matchingEvent;
|
return matchingEvent?.thumb_path ?? "";
|
||||||
},
|
},
|
||||||
[events, getSegmentStart, getSegmentEnd, severityType],
|
[events, getSegmentStart, getSegmentEnd, severityType],
|
||||||
);
|
);
|
||||||
@ -214,6 +214,6 @@ export const useEventSegmentUtils = (
|
|||||||
getReviewed,
|
getReviewed,
|
||||||
shouldShowRoundedCorners,
|
shouldShowRoundedCorners,
|
||||||
getEventStart,
|
getEventStart,
|
||||||
getEvent,
|
getEventThumbnail,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -157,11 +157,9 @@ function MobileMenuItem({
|
|||||||
const { t } = useTranslation(["views/settings"]);
|
const { t } = useTranslation(["views/settings"]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Button
|
||||||
className={cn(
|
variant="ghost"
|
||||||
"inline-flex h-10 w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md px-4 py-2 pr-2 text-sm font-medium text-primary-variant disabled:pointer-events-none disabled:opacity-50",
|
className={cn("w-full justify-between pr-2", className)}
|
||||||
className,
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSelect(item.key);
|
onSelect(item.key);
|
||||||
onClose?.();
|
onClose?.();
|
||||||
@ -169,7 +167,7 @@ function MobileMenuItem({
|
|||||||
>
|
>
|
||||||
<div className="smart-capitalize">{t("menu." + item.key)}</div>
|
<div className="smart-capitalize">{t("menu." + item.key)}</div>
|
||||||
<LuChevronRight className="size-4" />
|
<LuChevronRight className="size-4" />
|
||||||
</div>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -275,9 +273,6 @@ export default function Settings() {
|
|||||||
} else {
|
} else {
|
||||||
setPageToggle(page as SettingsType);
|
setPageToggle(page as SettingsType);
|
||||||
}
|
}
|
||||||
if (isMobile) {
|
|
||||||
setContentMobileOpen(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// don't clear url params if we're creating a new object mask
|
// don't clear url params if we're creating a new object mask
|
||||||
return !(searchParams.has("object_mask") || searchParams.has("event_id"));
|
return !(searchParams.has("object_mask") || searchParams.has("event_id"));
|
||||||
@ -287,9 +282,6 @@ export default function Settings() {
|
|||||||
const cameraNames = cameras.map((c) => c.name);
|
const cameraNames = cameras.map((c) => c.name);
|
||||||
if (cameraNames.includes(camera)) {
|
if (cameraNames.includes(camera)) {
|
||||||
setSelectedCamera(camera);
|
setSelectedCamera(camera);
|
||||||
if (isMobile) {
|
|
||||||
setContentMobileOpen(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// don't clear url params if we're creating a new object mask or trigger
|
// don't clear url params if we're creating a new object mask or trigger
|
||||||
return !(searchParams.has("object_mask") || searchParams.has("event_id"));
|
return !(searchParams.has("object_mask") || searchParams.has("event_id"));
|
||||||
|
|||||||
@ -306,7 +306,6 @@ export type CustomClassificationModelConfig = {
|
|||||||
threshold: number;
|
threshold: number;
|
||||||
object_config?: {
|
object_config?: {
|
||||||
objects: string[];
|
objects: string[];
|
||||||
classification_type: string;
|
|
||||||
};
|
};
|
||||||
state_config?: {
|
state_config?: {
|
||||||
cameras: {
|
cameras: {
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import ClassificationModelWizardDialog from "@/components/classification/ClassificationModelWizardDialog";
|
import ClassificationModelWizardDialog from "@/components/classification/ClassificationModelWizardDialog";
|
||||||
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 { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
@ -15,7 +14,7 @@ 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";
|
||||||
import { LuPencil, LuTrash2 } from "react-icons/lu";
|
import { LuTrash2 } from "react-icons/lu";
|
||||||
import { FiMoreVertical } from "react-icons/fi";
|
import { FiMoreVertical } from "react-icons/fi";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
@ -164,7 +163,6 @@ export default function ModelSelectionView({
|
|||||||
key={config.name}
|
key={config.name}
|
||||||
config={config}
|
config={config}
|
||||||
onClick={() => onClick(config)}
|
onClick={() => onClick(config)}
|
||||||
onUpdate={() => refreshConfig()}
|
|
||||||
onDelete={() => refreshConfig()}
|
onDelete={() => refreshConfig()}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -203,10 +201,9 @@ function NoModelsView({
|
|||||||
type ModelCardProps = {
|
type ModelCardProps = {
|
||||||
config: CustomClassificationModelConfig;
|
config: CustomClassificationModelConfig;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
onUpdate: () => void;
|
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
};
|
};
|
||||||
function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
|
||||||
const { t } = useTranslation(["views/classificationModel"]);
|
const { t } = useTranslation(["views/classificationModel"]);
|
||||||
|
|
||||||
const { data: dataset } = useSWR<{
|
const { data: dataset } = useSWR<{
|
||||||
@ -214,7 +211,6 @@ function ModelCard({ config, onClick, onUpdate, 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 [editDialogOpen, setEditDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleDelete = useCallback(async () => {
|
const handleDelete = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -254,11 +250,6 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
|||||||
setDeleteDialogOpen(true);
|
setDeleteDialogOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleEditClick = useCallback((e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setEditDialogOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const coverImage = useMemo(() => {
|
const coverImage = useMemo(() => {
|
||||||
if (!dataset) {
|
if (!dataset) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -279,13 +270,6 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ClassificationModelEditDialog
|
|
||||||
open={editDialogOpen}
|
|
||||||
model={config}
|
|
||||||
onClose={() => setEditDialogOpen(false)}
|
|
||||||
onSuccess={() => onUpdate()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={deleteDialogOpen}
|
open={deleteDialogOpen}
|
||||||
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
|
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
|
||||||
@ -336,10 +320,6 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
|||||||
align="end"
|
align="end"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem onClick={handleEditClick}>
|
|
||||||
<LuPencil className="mr-2 size-4" />
|
|
||||||
<span>{t("button.edit", { ns: "common" })}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={handleDeleteClick}>
|
<DropdownMenuItem onClick={handleDeleteClick}>
|
||||||
<LuTrash2 className="mr-2 size-4" />
|
<LuTrash2 className="mr-2 size-4" />
|
||||||
<span>{t("button.delete", { ns: "common" })}</span>
|
<span>{t("button.delete", { ns: "common" })}</span>
|
||||||
|
|||||||
@ -327,39 +327,31 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
|||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
<div className="flex flex-row justify-between gap-2 p-2 align-middle">
|
<div className="flex flex-row justify-between gap-2 p-2 align-middle">
|
||||||
{(isDesktop || !selectedImages?.length) && (
|
<div className="flex flex-row items-center justify-center gap-2">
|
||||||
<div className="flex flex-row items-center justify-center gap-2">
|
<Button
|
||||||
<Button
|
className="flex items-center gap-2.5 rounded-lg"
|
||||||
className="flex items-center gap-2.5 rounded-lg"
|
aria-label={t("label.back", { ns: "common" })}
|
||||||
aria-label={t("label.back", { ns: "common" })}
|
onClick={() => navigate(-1)}
|
||||||
onClick={() => navigate(-1)}
|
|
||||||
>
|
|
||||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
|
||||||
{isDesktop && (
|
|
||||||
<div className="text-primary">
|
|
||||||
{t("button.back", { ns: "common" })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<LibrarySelector
|
|
||||||
pageToggle={pageToggle}
|
|
||||||
dataset={dataset || {}}
|
|
||||||
trainImages={trainImages || []}
|
|
||||||
setPageToggle={setPageToggle}
|
|
||||||
onDelete={onDelete}
|
|
||||||
onRename={() => {}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedImages?.length > 0 ? (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center justify-end gap-2",
|
|
||||||
isMobileOnly && "justify-between",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="flex w-48 items-center justify-center text-sm text-muted-foreground">
|
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||||
|
{isDesktop && (
|
||||||
|
<div className="text-primary">
|
||||||
|
{t("button.back", { ns: "common" })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<LibrarySelector
|
||||||
|
pageToggle={pageToggle}
|
||||||
|
dataset={dataset || {}}
|
||||||
|
trainImages={trainImages || []}
|
||||||
|
setPageToggle={setPageToggle}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onRename={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{selectedImages?.length > 0 ? (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div className="mx-1 flex w-48 items-center justify-center text-sm text-muted-foreground">
|
||||||
<div className="p-1">{`${selectedImages.length} selected`}</div>
|
<div className="p-1">{`${selectedImages.length} selected`}</div>
|
||||||
<div className="p-1">{"|"}</div>
|
<div className="p-1">{"|"}</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -970,11 +970,12 @@ function Timeline({
|
|||||||
"relative overflow-hidden",
|
"relative overflow-hidden",
|
||||||
isDesktop
|
isDesktop
|
||||||
? cn(
|
? cn(
|
||||||
|
"no-scrollbar overflow-y-auto",
|
||||||
timelineType == "timeline"
|
timelineType == "timeline"
|
||||||
? "w-[100px] flex-shrink-0"
|
? "w-[100px] flex-shrink-0"
|
||||||
: timelineType == "detail"
|
: timelineType == "detail"
|
||||||
? "min-w-[20rem] max-w-[30%] flex-shrink-0 flex-grow-0 basis-[30rem] md:min-w-[20rem] md:max-w-[25%] lg:min-w-[30rem] lg:max-w-[33%]"
|
? "min-w-[20rem] max-w-[30%] flex-shrink-0 flex-grow-0 basis-[30rem] md:min-w-[20rem] md:max-w-[25%] lg:min-w-[30rem] lg:max-w-[33%]"
|
||||||
: "w-80 flex-shrink-0",
|
: "w-60 flex-shrink-0",
|
||||||
)
|
)
|
||||||
: cn(
|
: cn(
|
||||||
timelineType == "timeline"
|
timelineType == "timeline"
|
||||||
|
|||||||
@ -717,11 +717,11 @@ export default function CameraSettingsView({
|
|||||||
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
|
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
|
||||||
<Button
|
<Button
|
||||||
className="flex flex-1"
|
className="flex flex-1"
|
||||||
aria-label={t("button.reset", { ns: "common" })}
|
aria-label={t("button.cancel", { ns: "common" })}
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Trans>button.reset</Trans>
|
<Trans>button.cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="select"
|
variant="select"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user