diff --git a/frigate/config.py b/frigate/config.py
index 2894fa42a..d7ee147f3 100644
--- a/frigate/config.py
+++ b/frigate/config.py
@@ -533,6 +533,14 @@ class ZoneConfig(BaseModel):
def contour(self) -> np.ndarray:
return self._contour
+ @field_validator("objects", mode="before")
+ @classmethod
+ def validate_objects(cls, v):
+ if isinstance(v, str) and "," not in v:
+ return [v]
+
+ return v
+
def __init__(self, **config):
super().__init__(**config)
@@ -613,6 +621,14 @@ class AlertsConfig(FrigateBaseModel):
title="List of required zones to be entered in order to save the event as an alert.",
)
+ @field_validator("required_zones", mode="before")
+ @classmethod
+ def validate_required_zones(cls, v):
+ if isinstance(v, str) and "," not in v:
+ return [v]
+
+ return v
+
class DetectionsConfig(FrigateBaseModel):
"""Configure detections"""
@@ -625,6 +641,14 @@ class DetectionsConfig(FrigateBaseModel):
title="List of required zones to be entered in order to save the event as a detection.",
)
+ @field_validator("required_zones", mode="before")
+ @classmethod
+ def validate_required_zones(cls, v):
+ if isinstance(v, str) and "," not in v:
+ return [v]
+
+ return v
+
class ReviewConfig(FrigateBaseModel):
"""Configure reviews"""
diff --git a/web/package-lock.json b/web/package-lock.json
index 262c11421..168a00acd 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -9,7 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@cycjimmy/jsmpeg-player": "^6.0.5",
- "@hookform/resolvers": "^3.3.2",
+ "@hookform/resolvers": "^3.3.4",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-aspect-ratio": "^1.0.3",
"@radix-ui/react-context-menu": "^2.1.5",
@@ -21,6 +21,7 @@
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
+ "@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
@@ -37,6 +38,7 @@
"hls.js": "^1.5.8",
"idb-keyval": "^6.2.1",
"immer": "^10.0.4",
+ "konva": "^9.3.6",
"lucide-react": "^0.368.0",
"monaco-yaml": "^5.1.1",
"next-themes": "^0.3.0",
@@ -47,6 +49,7 @@
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.3",
"react-icons": "^5.0.1",
+ "react-konva": "^18.2.10",
"react-router-dom": "^6.22.3",
"react-swipeable": "^7.0.1",
"react-tracked": "^1.7.14",
@@ -1648,6 +1651,29 @@
}
}
},
+ "node_modules/@radix-ui/react-separator": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.0.3.tgz",
+ "integrity": "sha512-itYmTy/kokS21aiV5+Z56MZB54KrhPgn6eHDKkFeOLR34HMN2s8PaN47qZZAGnvupcjxHaFZnW4pQEh0BvvVuw==",
+ "dependencies": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-primitive": "1.0.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0",
+ "react-dom": "^16.8 || ^17.0 || ^18.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-slider": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.1.2.tgz",
@@ -2518,8 +2544,7 @@
"node_modules/@types/prop-types": {
"version": "15.7.11",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
- "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==",
- "devOptional": true
+ "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng=="
},
"node_modules/@types/react": {
"version": "18.2.78",
@@ -2550,6 +2575,14 @@
"react-icons": "*"
}
},
+ "node_modules/@types/react-reconciler": {
+ "version": "0.28.8",
+ "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.8.tgz",
+ "integrity": "sha512-SN9c4kxXZonFhbX4hJrZy37yw9e7EIxcpHCxQv5JUS18wDE5ovkQKlqQEkufdJCCMfuI9BnjUJvhYeJ9x5Ra7g==",
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
"node_modules/@types/react-transition-group": {
"version": "4.4.10",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz",
@@ -5046,6 +5079,17 @@
"node": ">=8"
}
},
+ "node_modules/its-fine": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.1.3.tgz",
+ "integrity": "sha512-mncCA+yb6tuh5zK26cHqKlsSyxm4zdm4YgJpxycyx6p9fgxgK5PLu3iDVpKhzTn57Yrv3jk/r0aK0RFTT1OjFw==",
+ "dependencies": {
+ "@types/react-reconciler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": ">=18.0"
+ }
+ },
"node_modules/jest-diff": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz",
@@ -5177,6 +5221,25 @@
"json-buffer": "3.0.1"
}
},
+ "node_modules/konva": {
+ "version": "9.3.6",
+ "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.6.tgz",
+ "integrity": "sha512-dqR8EbcM0hjuilZCBP6xauQ5V3kH3m9kBcsDkqPypQuRgsXbcXUrxqYxhNbdvKZpYNW8Amq94jAD/C0NY3qfBQ==",
+ "funding": [
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/lavrton"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/konva"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/lavrton"
+ }
+ ]
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -6289,6 +6352,51 @@
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
"dev": true
},
+ "node_modules/react-konva": {
+ "version": "18.2.10",
+ "resolved": "https://registry.npmjs.org/react-konva/-/react-konva-18.2.10.tgz",
+ "integrity": "sha512-ohcX1BJINL43m4ynjZ24MxFI1syjBdrXhqVxYVDw2rKgr3yuS0x/6m1Y2Z4sl4T/gKhfreBx8KHisd0XC6OT1g==",
+ "funding": [
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/lavrton"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/konva"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/lavrton"
+ }
+ ],
+ "dependencies": {
+ "@types/react-reconciler": "^0.28.2",
+ "its-fine": "^1.1.1",
+ "react-reconciler": "~0.29.0",
+ "scheduler": "^0.23.0"
+ },
+ "peerDependencies": {
+ "konva": "^8.0.1 || ^7.2.5 || ^9.0.0",
+ "react": ">=18.0.0",
+ "react-dom": ">=18.0.0"
+ }
+ },
+ "node_modules/react-reconciler": {
+ "version": "0.29.0",
+ "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.0.tgz",
+ "integrity": "sha512-wa0fGj7Zht1EYMRhKWwoo1H9GApxYLBuhoAuXN0TlltESAjDssB+Apf0T/DngVqaMyPypDmabL37vw/2aRM98Q==",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "peerDependencies": {
+ "react": "^18.2.0"
+ }
+ },
"node_modules/react-remove-scroll": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz",
diff --git a/web/package.json b/web/package.json
index 626b9a409..b01cadc54 100644
--- a/web/package.json
+++ b/web/package.json
@@ -14,7 +14,7 @@
},
"dependencies": {
"@cycjimmy/jsmpeg-player": "^6.0.5",
- "@hookform/resolvers": "^3.3.2",
+ "@hookform/resolvers": "^3.3.4",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-aspect-ratio": "^1.0.3",
"@radix-ui/react-context-menu": "^2.1.5",
@@ -26,6 +26,7 @@
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
+ "@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
@@ -42,6 +43,7 @@
"hls.js": "^1.5.8",
"idb-keyval": "^6.2.1",
"immer": "^10.0.4",
+ "konva": "^9.3.6",
"lucide-react": "^0.368.0",
"monaco-yaml": "^5.1.1",
"next-themes": "^0.3.0",
@@ -52,6 +54,7 @@
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.3",
"react-icons": "^5.0.1",
+ "react-konva": "^18.2.10",
"react-router-dom": "^6.22.3",
"react-swipeable": "^7.0.1",
"react-tracked": "^1.7.14",
diff --git a/web/src/api/ws.tsx b/web/src/api/ws.tsx
index a6638a94d..2439dbb33 100644
--- a/web/src/api/ws.tsx
+++ b/web/src/api/ws.tsx
@@ -206,3 +206,45 @@ export function useAudioActivity(camera: string): { payload: number } {
} = useWs(`${camera}/audio/rms`, "");
return { payload: payload as number };
}
+
+export function useMotionThreshold(camera: string): {
+ payload: string;
+ send: (payload: number, retain?: boolean) => void;
+} {
+ const {
+ value: { payload },
+ send,
+ } = useWs(
+ `${camera}/motion_threshold/state`,
+ `${camera}/motion_threshold/set`,
+ );
+ return { payload: payload as string, send };
+}
+
+export function useMotionContourArea(camera: string): {
+ payload: string;
+ send: (payload: number, retain?: boolean) => void;
+} {
+ const {
+ value: { payload },
+ send,
+ } = useWs(
+ `${camera}/motion_contour_area/state`,
+ `${camera}/motion_contour_area/set`,
+ );
+ return { payload: payload as string, send };
+}
+
+export function useImproveContrast(camera: string): {
+ payload: ToggleableSetting;
+ send: (payload: string, retain?: boolean) => void;
+} {
+ const {
+ value: { payload },
+ send,
+ } = useWs(
+ `${camera}/improve_contrast/state`,
+ `${camera}/improve_contrast/set`,
+ );
+ return { payload: payload as ToggleableSetting, send };
+}
diff --git a/web/src/components/camera/AutoUpdatingCameraImage.tsx b/web/src/components/camera/AutoUpdatingCameraImage.tsx
index 2f2005d9c..28ad3b883 100644
--- a/web/src/components/camera/AutoUpdatingCameraImage.tsx
+++ b/web/src/components/camera/AutoUpdatingCameraImage.tsx
@@ -6,6 +6,7 @@ type AutoUpdatingCameraImageProps = {
searchParams?: URLSearchParams;
showFps?: boolean;
className?: string;
+ cameraClasses?: string;
reloadInterval?: number;
};
@@ -16,6 +17,7 @@ export default function AutoUpdatingCameraImage({
searchParams = undefined,
showFps = true,
className,
+ cameraClasses,
reloadInterval = MIN_LOAD_TIMEOUT_MS,
}: AutoUpdatingCameraImageProps) {
const [key, setKey] = useState(Date.now());
@@ -68,6 +70,7 @@ export default function AutoUpdatingCameraImage({
camera={camera}
onload={handleLoad}
searchParams={`cache=${key}&${searchParams}`}
+ className={cameraClasses}
/>
{showFps ? Displaying at {fps}fps : null}
diff --git a/web/src/components/camera/CameraImage.tsx b/web/src/components/camera/CameraImage.tsx
index be978f7a5..1f2c28ade 100644
--- a/web/src/components/camera/CameraImage.tsx
+++ b/web/src/components/camera/CameraImage.tsx
@@ -36,12 +36,7 @@ export default function CameraImage({
}, [apiHost, name, imgRef, searchParams, config]);
return (
-
+
{enabled ? (
diff --git a/web/src/components/dynamic/CameraFeatureToggle.tsx b/web/src/components/dynamic/CameraFeatureToggle.tsx
index aa33a019d..b0418c556 100644
--- a/web/src/components/dynamic/CameraFeatureToggle.tsx
+++ b/web/src/components/dynamic/CameraFeatureToggle.tsx
@@ -14,7 +14,7 @@ const variants = {
overlay: {
active: "font-bold text-white bg-selected rounded-full",
inactive:
- "text-primary-white rounded-full bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500",
+ "text-primary rounded-full bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500",
},
};
diff --git a/web/src/components/filter/LogLevelFilter.tsx b/web/src/components/filter/LogLevelFilter.tsx
index a9e58e463..0ec8af8b2 100644
--- a/web/src/components/filter/LogLevelFilter.tsx
+++ b/web/src/components/filter/LogLevelFilter.tsx
@@ -120,7 +120,6 @@ export function GeneralFilterContent({
))}
-
>
);
}
diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx
index 2a36b2a60..e4eafb8df 100644
--- a/web/src/components/filter/ReviewFilterGroup.tsx
+++ b/web/src/components/filter/ReviewFilterGroup.tsx
@@ -248,7 +248,7 @@ export function CamerasFilterButton({
>
)}
-
+
void;
+};
+export function ZoneMaskFilterButton({
+ selectedZoneMask,
+ updateZoneMaskFilter,
+}: ZoneMaskFilterButtonProps) {
+ const trigger = (
+
+
+
+ Filter
+
+
+ );
+ const content = (
+
+ );
+
+ if (isMobile) {
+ return (
+
+ {trigger}
+
+ {content}
+
+
+ );
+ }
+
+ return (
+
+ {trigger}
+ {content}
+
+ );
+}
+
+type GeneralFilterContentProps = {
+ selectedZoneMask: PolygonType[] | undefined;
+ updateZoneMaskFilter: (labels: PolygonType[] | undefined) => void;
+};
+export function GeneralFilterContent({
+ selectedZoneMask,
+ updateZoneMaskFilter,
+}: GeneralFilterContentProps) {
+ return (
+ <>
+
+
+
+ All Masks and Zones
+
+ {
+ if (isChecked) {
+ updateZoneMaskFilter(undefined);
+ }
+ }}
+ />
+
+
+
+ {["zone", "motion_mask", "object_mask"].map((item) => (
+
+
+ {item
+ .replace(/_/g, " ")
+ .replace(/\b\w/g, (char) => char.toUpperCase()) + "s"}
+
+ {
+ if (isChecked) {
+ const updatedLabels = selectedZoneMask
+ ? [...selectedZoneMask]
+ : [];
+
+ updatedLabels.push(item as PolygonType);
+ updateZoneMaskFilter(updatedLabels);
+ } else {
+ const updatedLabels = selectedZoneMask
+ ? [...selectedZoneMask]
+ : [];
+
+ // can not deselect the last item
+ if (updatedLabels.length > 1) {
+ updatedLabels.splice(
+ updatedLabels.indexOf(item as PolygonType),
+ 1,
+ );
+ updateZoneMaskFilter(updatedLabels);
+ }
+ }
+ }}
+ />
+
+ ))}
+
+
+ >
+ );
+}
diff --git a/web/src/components/settings/AccountSettings.tsx b/web/src/components/menu/AccountSettings.tsx
similarity index 100%
rename from web/src/components/settings/AccountSettings.tsx
rename to web/src/components/menu/AccountSettings.tsx
diff --git a/web/src/components/settings/GeneralSettings.tsx b/web/src/components/menu/GeneralSettings.tsx
similarity index 100%
rename from web/src/components/settings/GeneralSettings.tsx
rename to web/src/components/menu/GeneralSettings.tsx
diff --git a/web/src/components/navigation/Bottombar.tsx b/web/src/components/navigation/Bottombar.tsx
index 2ef8c2e8d..e21556aaa 100644
--- a/web/src/components/navigation/Bottombar.tsx
+++ b/web/src/components/navigation/Bottombar.tsx
@@ -6,8 +6,8 @@ import { FrigateStats } from "@/types/stats";
import { useFrigateStats } from "@/api/ws";
import { useMemo } from "react";
import useStats from "@/hooks/use-stats";
-import GeneralSettings from "../settings/GeneralSettings";
-import AccountSettings from "../settings/AccountSettings";
+import GeneralSettings from "../menu/GeneralSettings";
+import AccountSettings from "../menu/AccountSettings";
import useNavigation from "@/hooks/use-navigation";
function Bottombar() {
diff --git a/web/src/components/navigation/Sidebar.tsx b/web/src/components/navigation/Sidebar.tsx
index e4b4d9c81..ea895a12f 100644
--- a/web/src/components/navigation/Sidebar.tsx
+++ b/web/src/components/navigation/Sidebar.tsx
@@ -2,8 +2,8 @@ import Logo from "../Logo";
import NavItem from "./NavItem";
import { CameraGroupSelector } from "../filter/CameraGroupSelector";
import { useLocation } from "react-router-dom";
-import GeneralSettings from "../settings/GeneralSettings";
-import AccountSettings from "../settings/AccountSettings";
+import GeneralSettings from "../menu/GeneralSettings";
+import AccountSettings from "../menu/AccountSettings";
import useNavigation from "@/hooks/use-navigation";
function Sidebar() {
diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx
index 925d7c88f..26c8c6477 100644
--- a/web/src/components/player/LivePlayer.tsx
+++ b/web/src/components/player/LivePlayer.tsx
@@ -163,6 +163,7 @@ export default function LivePlayer({
camera={cameraConfig.name}
showFps={false}
reloadInterval={stillReloadInterval}
+ cameraClasses="relative w-full h-full flex justify-center"
/>
diff --git a/web/src/components/settings/General.tsx b/web/src/components/settings/General.tsx
new file mode 100644
index 000000000..4d8465299
--- /dev/null
+++ b/web/src/components/settings/General.tsx
@@ -0,0 +1,40 @@
+import Heading from "@/components/ui/heading";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+
+export default function General() {
+ return (
+ <>
+
Settings
+
+ {}} />
+ Low Data Mode (this device only)
+
+
+
+
+
+
+
+
+
+ Live Mode
+ JSMpeg
+ MSE
+ WebRTC
+
+
+
+
+ >
+ );
+}
diff --git a/web/src/components/settings/MasksAndZones.tsx b/web/src/components/settings/MasksAndZones.tsx
new file mode 100644
index 000000000..381b1d952
--- /dev/null
+++ b/web/src/components/settings/MasksAndZones.tsx
@@ -0,0 +1,634 @@
+import { FrigateConfig } from "@/types/frigateConfig";
+import useSWR from "swr";
+import ActivityIndicator from "@/components/indicators/activity-indicator";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { PolygonCanvas } from "./PolygonCanvas";
+import { Polygon, PolygonType } from "@/types/canvas";
+import { interpolatePoints, parseCoordinates } from "@/utils/canvasUtil";
+import { Skeleton } from "../ui/skeleton";
+import { useResizeObserver } from "@/hooks/resize-observer";
+import { LuExternalLink, LuPlus } from "react-icons/lu";
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+} from "@/components/ui/hover-card";
+import copy from "copy-to-clipboard";
+import { toast } from "sonner";
+import { Toaster } from "../ui/sonner";
+import { Button } from "../ui/button";
+import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
+import Heading from "../ui/heading";
+import ZoneEditPane from "./ZoneEditPane";
+import MotionMaskEditPane from "./MotionMaskEditPane";
+import ObjectMaskEditPane from "./ObjectMaskEditPane";
+import PolygonItem from "./PolygonItem";
+import { Link } from "react-router-dom";
+import { isDesktop } from "react-device-detect";
+
+type MasksAndZoneProps = {
+ selectedCamera: string;
+ selectedZoneMask?: PolygonType[];
+ setUnsavedChanges: React.Dispatch
>;
+};
+
+export default function MasksAndZones({
+ selectedCamera,
+ selectedZoneMask,
+ setUnsavedChanges,
+}: MasksAndZoneProps) {
+ const { data: config } = useSWR("config");
+ const [allPolygons, setAllPolygons] = useState([]);
+ const [editingPolygons, setEditingPolygons] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [activePolygonIndex, setActivePolygonIndex] = useState<
+ number | undefined
+ >(undefined);
+ const [hoveredPolygonIndex, setHoveredPolygonIndex] = useState(
+ null,
+ );
+ const containerRef = useRef(null);
+ const [editPane, setEditPane] = useState(undefined);
+
+ const cameraConfig = useMemo(() => {
+ if (config && selectedCamera) {
+ return config.cameras[selectedCamera];
+ }
+ }, [config, selectedCamera]);
+
+ const [{ width: containerWidth, height: containerHeight }] =
+ useResizeObserver(containerRef);
+
+ const aspectRatio = useMemo(() => {
+ if (!config) {
+ return undefined;
+ }
+
+ const camera = config.cameras[selectedCamera];
+
+ if (!camera) {
+ return undefined;
+ }
+
+ return camera.detect.width / camera.detect.height;
+ }, [config, selectedCamera]);
+
+ const detectHeight = useMemo(() => {
+ if (!config) {
+ return undefined;
+ }
+
+ const camera = config.cameras[selectedCamera];
+
+ if (!camera) {
+ return undefined;
+ }
+
+ return camera.detect.height;
+ }, [config, selectedCamera]);
+
+ const stretch = true;
+ // may need tweaking for mobile
+ const fitAspect = isDesktop ? 16 / 9 : 3 / 4;
+
+ const scaledHeight = useMemo(() => {
+ if (containerRef.current && aspectRatio && detectHeight) {
+ const scaledHeight =
+ aspectRatio < (fitAspect ?? 0)
+ ? Math.floor(
+ Math.min(containerHeight, containerRef.current?.clientHeight),
+ )
+ : isDesktop || aspectRatio > fitAspect
+ ? Math.floor(containerWidth / aspectRatio)
+ : Math.floor(containerWidth / aspectRatio) / 1.5;
+ const finalHeight = stretch
+ ? scaledHeight
+ : Math.min(scaledHeight, detectHeight);
+
+ if (finalHeight > 0) {
+ return finalHeight;
+ }
+ }
+ }, [
+ aspectRatio,
+ containerWidth,
+ containerHeight,
+ fitAspect,
+ detectHeight,
+ stretch,
+ ]);
+
+ const scaledWidth = useMemo(() => {
+ if (aspectRatio && scaledHeight) {
+ return Math.ceil(scaledHeight * aspectRatio);
+ }
+ }, [scaledHeight, aspectRatio]);
+
+ const handleNewPolygon = (type: PolygonType) => {
+ if (!cameraConfig) {
+ return;
+ }
+
+ setActivePolygonIndex(allPolygons.length);
+
+ let polygonColor = [128, 128, 0];
+
+ if (type == "motion_mask") {
+ polygonColor = [0, 0, 220];
+ }
+ if (type == "object_mask") {
+ polygonColor = [128, 128, 128];
+ }
+
+ setEditingPolygons([
+ ...(allPolygons || []),
+ {
+ points: [],
+ isFinished: false,
+ type,
+ typeIndex: 9999,
+ name: "",
+ objects: [],
+ camera: selectedCamera,
+ color: polygonColor,
+ },
+ ]);
+ };
+
+ const handleCancel = useCallback(() => {
+ setEditPane(undefined);
+ setEditingPolygons([...allPolygons]);
+ setActivePolygonIndex(undefined);
+ setHoveredPolygonIndex(null);
+ setUnsavedChanges(false);
+ }, [allPolygons, setUnsavedChanges]);
+
+ const handleSave = useCallback(() => {
+ setAllPolygons([...(editingPolygons ?? [])]);
+ setHoveredPolygonIndex(null);
+ setUnsavedChanges(false);
+ }, [editingPolygons, setUnsavedChanges]);
+
+ useEffect(() => {
+ if (isLoading) {
+ return;
+ }
+ if (!isLoading && editPane !== undefined) {
+ setActivePolygonIndex(undefined);
+ setEditPane(undefined);
+ }
+ // we know that these deps are correct
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isLoading]);
+
+ const handleCopyCoordinates = useCallback(
+ (index: number) => {
+ if (allPolygons && scaledWidth && scaledHeight) {
+ const poly = allPolygons[index];
+ copy(
+ interpolatePoints(poly.points, scaledWidth, scaledHeight, 1, 1)
+ .map((point) => `${point[0]},${point[1]}`)
+ .join(","),
+ );
+ toast.success(`Copied coordinates for ${poly.name} to clipboard.`);
+ } else {
+ toast.error("Could not copy coordinates to clipboard.");
+ }
+ },
+ [allPolygons, scaledHeight, scaledWidth],
+ );
+
+ useEffect(() => {
+ if (cameraConfig && containerRef.current && scaledWidth && scaledHeight) {
+ const zones = Object.entries(cameraConfig.zones).map(
+ ([name, zoneData], index) => ({
+ type: "zone" as PolygonType,
+ typeIndex: index,
+ camera: cameraConfig.name,
+ name,
+ objects: zoneData.objects,
+ points: interpolatePoints(
+ parseCoordinates(zoneData.coordinates),
+ 1,
+ 1,
+ scaledWidth,
+ scaledHeight,
+ ),
+ isFinished: true,
+ color: zoneData.color,
+ }),
+ );
+
+ let motionMasks: Polygon[] = [];
+ let globalObjectMasks: Polygon[] = [];
+ let objectMasks: Polygon[] = [];
+
+ // this can be an array or a string
+ motionMasks = (
+ Array.isArray(cameraConfig.motion.mask)
+ ? cameraConfig.motion.mask
+ : cameraConfig.motion.mask
+ ? [cameraConfig.motion.mask]
+ : []
+ ).map((maskData, index) => ({
+ type: "motion_mask" as PolygonType,
+ typeIndex: index,
+ camera: cameraConfig.name,
+ name: `Motion Mask ${index + 1}`,
+ objects: [],
+ points: interpolatePoints(
+ parseCoordinates(maskData),
+ 1,
+ 1,
+ scaledWidth,
+ scaledHeight,
+ ),
+ isFinished: true,
+ color: [0, 0, 255],
+ }));
+
+ const globalObjectMasksArray = Array.isArray(cameraConfig.objects.mask)
+ ? cameraConfig.objects.mask
+ : cameraConfig.objects.mask
+ ? [cameraConfig.objects.mask]
+ : [];
+
+ globalObjectMasks = globalObjectMasksArray.map((maskData, index) => ({
+ type: "object_mask" as PolygonType,
+ typeIndex: index,
+ camera: cameraConfig.name,
+ name: `Object Mask ${index + 1} (all objects)`,
+ objects: [],
+ points: interpolatePoints(
+ parseCoordinates(maskData),
+ 1,
+ 1,
+ scaledWidth,
+ scaledHeight,
+ ),
+ isFinished: true,
+ color: [128, 128, 128],
+ }));
+
+ const globalObjectMasksCount = globalObjectMasks.length;
+ let index = 0;
+
+ objectMasks = Object.entries(cameraConfig.objects.filters)
+ .filter(([, { mask }]) => mask || Array.isArray(mask))
+ .flatMap(([objectName, { mask }]): Polygon[] => {
+ const maskArray = Array.isArray(mask) ? mask : mask ? [mask] : [];
+ return maskArray.flatMap((maskItem, subIndex) => {
+ const maskItemString = maskItem;
+ const newMask = {
+ type: "object_mask" as PolygonType,
+ typeIndex: subIndex,
+ camera: cameraConfig.name,
+ name: `Object Mask ${globalObjectMasksCount + index + 1} (${objectName})`,
+ objects: [objectName],
+ points: interpolatePoints(
+ parseCoordinates(maskItem),
+ 1,
+ 1,
+ scaledWidth,
+ scaledHeight,
+ ),
+ isFinished: true,
+ color: [128, 128, 128],
+ };
+ index++;
+
+ if (
+ globalObjectMasksArray.some(
+ (globalMask) => globalMask === maskItemString,
+ )
+ ) {
+ index--;
+ return [];
+ } else {
+ return [newMask];
+ }
+ });
+ });
+
+ setAllPolygons([
+ ...zones,
+ ...motionMasks,
+ ...globalObjectMasks,
+ ...objectMasks,
+ ]);
+ setEditingPolygons([
+ ...zones,
+ ...motionMasks,
+ ...globalObjectMasks,
+ ...objectMasks,
+ ]);
+ }
+ // we know that these deps are correct
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [cameraConfig, containerRef, scaledHeight, scaledWidth]);
+
+ useEffect(() => {
+ if (editPane === undefined) {
+ setEditingPolygons([...allPolygons]);
+ }
+ // we know that these deps are correct
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [setEditingPolygons, allPolygons]);
+
+ useEffect(() => {
+ if (selectedCamera) {
+ setActivePolygonIndex(undefined);
+ setEditPane(undefined);
+ }
+ }, [selectedCamera]);
+
+ if (!cameraConfig && !selectedCamera) {
+ return ;
+ }
+
+ return (
+ <>
+ {cameraConfig && editingPolygons && (
+
+
+
+ {editPane == "zone" && (
+
+ )}
+ {editPane == "motion_mask" && (
+
+ )}
+ {editPane == "object_mask" && (
+
+ )}
+ {editPane === undefined && (
+ <>
+
+ Masks / Zones
+
+
+ {(selectedZoneMask === undefined ||
+ selectedZoneMask.includes("zone" as PolygonType)) && (
+
+
+
+
+ Zones
+
+
+
+
+ Zones allow you to define a specific area of the
+ frame so you can determine whether or not an
+ object is within a particular area.
+
+
+
+ Documentation{" "}
+
+
+
+
+
+
+
+
+ {
+ setEditPane("zone");
+ handleNewPolygon("zone");
+ }}
+ >
+
+
+
+ Add Zone
+
+
+ {allPolygons
+ .flatMap((polygon, index) =>
+ polygon.type === "zone" ? [{ polygon, index }] : [],
+ )
+ .map(({ polygon, index }) => (
+
+ ))}
+
+ )}
+ {(selectedZoneMask === undefined ||
+ selectedZoneMask.includes(
+ "motion_mask" as PolygonType,
+ )) && (
+
+
+
+
+
+ Motion Masks
+
+
+
+
+
+ Motion masks are used to prevent unwanted types
+ of motion from triggering detection. Over
+ masking will make it more difficult for objects
+ to be tracked.
+
+
+
+ Documentation{" "}
+
+
+
+
+
+
+
+
+ {
+ setEditPane("motion_mask");
+ handleNewPolygon("motion_mask");
+ }}
+ >
+
+
+
+ Add Motion Mask
+
+
+ {allPolygons
+ .flatMap((polygon, index) =>
+ polygon.type === "motion_mask"
+ ? [{ polygon, index }]
+ : [],
+ )
+ .map(({ polygon, index }) => (
+
+ ))}
+
+ )}
+ {(selectedZoneMask === undefined ||
+ selectedZoneMask.includes(
+ "object_mask" as PolygonType,
+ )) && (
+
+
+
+
+
+ Object Masks
+
+
+
+
+
+ Object filter masks are used to filter out false
+ positives for a given object type based on
+ location.
+
+
+
+ Documentation{" "}
+
+
+
+
+
+
+
+
+ {
+ setEditPane("object_mask");
+ handleNewPolygon("object_mask");
+ }}
+ >
+
+
+
+ Add Object Mask
+
+
+ {allPolygons
+ .flatMap((polygon, index) =>
+ polygon.type === "object_mask"
+ ? [{ polygon, index }]
+ : [],
+ )
+ .map(({ polygon, index }) => (
+
+ ))}
+
+ )}
+
+ >
+ )}
+
+
+
+ {cameraConfig &&
+ scaledWidth &&
+ scaledHeight &&
+ editingPolygons ? (
+
+ ) : (
+
+ )}
+
+
+
+ )}
+ >
+ );
+}
diff --git a/web/src/components/settings/MotionMaskEditPane.tsx b/web/src/components/settings/MotionMaskEditPane.tsx
new file mode 100644
index 000000000..5b54a1122
--- /dev/null
+++ b/web/src/components/settings/MotionMaskEditPane.tsx
@@ -0,0 +1,268 @@
+import Heading from "../ui/heading";
+import { Separator } from "../ui/separator";
+import { Button } from "@/components/ui/button";
+import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
+import { useCallback, useMemo } from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import PolygonEditControls from "./PolygonEditControls";
+import { FaCheckCircle } from "react-icons/fa";
+import { Polygon } from "@/types/canvas";
+import useSWR from "swr";
+import { FrigateConfig } from "@/types/frigateConfig";
+import {
+ flattenPoints,
+ interpolatePoints,
+ parseCoordinates,
+} from "@/utils/canvasUtil";
+import axios from "axios";
+import { toast } from "sonner";
+import { Toaster } from "../ui/sonner";
+import ActivityIndicator from "../indicators/activity-indicator";
+
+type MotionMaskEditPaneProps = {
+ polygons?: Polygon[];
+ setPolygons: React.Dispatch>;
+ activePolygonIndex?: number;
+ scaledWidth?: number;
+ scaledHeight?: number;
+ isLoading: boolean;
+ setIsLoading: React.Dispatch>;
+ onSave?: () => void;
+ onCancel?: () => void;
+};
+
+export default function MotionMaskEditPane({
+ polygons,
+ setPolygons,
+ activePolygonIndex,
+ scaledWidth,
+ scaledHeight,
+ isLoading,
+ setIsLoading,
+ onSave,
+ onCancel,
+}: MotionMaskEditPaneProps) {
+ const { data: config, mutate: updateConfig } =
+ useSWR("config");
+
+ const polygon = useMemo(() => {
+ if (polygons && activePolygonIndex !== undefined) {
+ return polygons[activePolygonIndex];
+ } else {
+ return null;
+ }
+ }, [polygons, activePolygonIndex]);
+
+ const cameraConfig = useMemo(() => {
+ if (polygon?.camera && config) {
+ return config.cameras[polygon.camera];
+ }
+ }, [polygon, config]);
+
+ const defaultName = useMemo(() => {
+ if (!polygons) {
+ return;
+ }
+
+ const count = polygons.filter((poly) => poly.type == "motion_mask").length;
+
+ return `Motion Mask ${count + 1}`;
+ }, [polygons]);
+
+ const formSchema = z
+ .object({
+ polygon: z.object({ name: z.string(), isFinished: z.boolean() }),
+ })
+ .refine(() => polygon?.isFinished === true, {
+ message: "The polygon drawing must be finished before saving.",
+ path: ["polygon.isFinished"],
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ mode: "onChange",
+ defaultValues: {
+ polygon: { isFinished: polygon?.isFinished ?? false, name: defaultName },
+ },
+ });
+
+ const saveToConfig = useCallback(async () => {
+ if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) {
+ return;
+ }
+
+ const coordinates = flattenPoints(
+ interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
+ ).join(",");
+
+ let index = Array.isArray(cameraConfig.motion.mask)
+ ? cameraConfig.motion.mask.length
+ : cameraConfig.motion.mask
+ ? 1
+ : 0;
+
+ const editingMask = polygon.name.length > 0;
+
+ // editing existing mask, not creating a new one
+ if (editingMask) {
+ index = polygon.typeIndex;
+ }
+
+ const filteredMask = (
+ Array.isArray(cameraConfig.motion.mask)
+ ? cameraConfig.motion.mask
+ : [cameraConfig.motion.mask]
+ ).filter((_, currentIndex) => currentIndex !== index);
+
+ filteredMask.splice(index, 0, coordinates);
+
+ const queryString = filteredMask
+ .map((pointsArray) => {
+ const coordinates = flattenPoints(parseCoordinates(pointsArray)).join(
+ ",",
+ );
+ return `cameras.${polygon?.camera}.motion.mask=${coordinates}&`;
+ })
+ .join("");
+
+ axios
+ .put(`config/set?${queryString}`, {
+ requires_restart: 0,
+ })
+ .then((res) => {
+ if (res.status === 200) {
+ toast.success(`${polygon.name || "Motion Mask"} has been saved.`, {
+ position: "top-center",
+ });
+ updateConfig();
+ } else {
+ toast.error(`Failed to save config changes: ${res.statusText}`, {
+ position: "top-center",
+ });
+ }
+ })
+ .catch((error) => {
+ toast.error(
+ `Failed to save config changes: ${error.response.data.message}`,
+ { position: "top-center" },
+ );
+ })
+ .finally(() => {
+ setIsLoading(false);
+ });
+ }, [
+ updateConfig,
+ polygon,
+ scaledWidth,
+ scaledHeight,
+ setIsLoading,
+ cameraConfig,
+ ]);
+
+ function onSubmit(values: z.infer) {
+ if (activePolygonIndex === undefined || !values || !polygons) {
+ return;
+ }
+ setIsLoading(true);
+
+ saveToConfig();
+ if (onSave) {
+ onSave();
+ }
+ }
+
+ if (!polygon) {
+ return;
+ }
+
+ return (
+ <>
+
+
+ {polygon.name.length ? "Edit" : "New"} Motion Mask
+
+
+
+ Motion masks are used to prevent unwanted types of motion from
+ triggering detection. Over masking will make it more difficult for
+ objects to be tracked.
+
+
+
+ {polygons && activePolygonIndex !== undefined && (
+
+
+ {polygons[activePolygonIndex].points.length}{" "}
+ {polygons[activePolygonIndex].points.length > 1 ||
+ polygons[activePolygonIndex].points.length == 0
+ ? "points"
+ : "point"}
+ {polygons[activePolygonIndex].isFinished && (
+
+ )}
+
+
+
+ )}
+
+ Click to draw a polygon on the image.
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/web/src/components/settings/MotionTuner.tsx b/web/src/components/settings/MotionTuner.tsx
new file mode 100644
index 000000000..fce762df5
--- /dev/null
+++ b/web/src/components/settings/MotionTuner.tsx
@@ -0,0 +1,303 @@
+import Heading from "@/components/ui/heading";
+import { FrigateConfig } from "@/types/frigateConfig";
+import useSWR from "swr";
+import axios from "axios";
+import ActivityIndicator from "@/components/indicators/activity-indicator";
+import AutoUpdatingCameraImage from "@/components/camera/AutoUpdatingCameraImage";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { Slider } from "@/components/ui/slider";
+import { Label } from "@/components/ui/label";
+import {
+ useImproveContrast,
+ useMotionContourArea,
+ useMotionThreshold,
+} from "@/api/ws";
+import { Skeleton } from "../ui/skeleton";
+import { Button } from "../ui/button";
+import { Switch } from "../ui/switch";
+import { Toaster } from "@/components/ui/sonner";
+import { toast } from "sonner";
+import { Separator } from "../ui/separator";
+import { Link } from "react-router-dom";
+import { LuExternalLink } from "react-icons/lu";
+
+type MotionTunerProps = {
+ selectedCamera: string;
+ setUnsavedChanges: React.Dispatch>;
+};
+
+type MotionSettings = {
+ threshold?: number;
+ contour_area?: number;
+ improve_contrast?: boolean;
+};
+
+export default function MotionTuner({
+ selectedCamera,
+ setUnsavedChanges,
+}: MotionTunerProps) {
+ const { data: config, mutate: updateConfig } =
+ useSWR("config");
+ const [changedValue, setChangedValue] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const { send: sendMotionThreshold } = useMotionThreshold(selectedCamera);
+ const { send: sendMotionContourArea } = useMotionContourArea(selectedCamera);
+ const { send: sendImproveContrast } = useImproveContrast(selectedCamera);
+
+ const [motionSettings, setMotionSettings] = useState({
+ threshold: undefined,
+ contour_area: undefined,
+ improve_contrast: undefined,
+ });
+
+ const [origMotionSettings, setOrigMotionSettings] = useState({
+ threshold: undefined,
+ contour_area: undefined,
+ improve_contrast: undefined,
+ });
+
+ const cameraConfig = useMemo(() => {
+ if (config && selectedCamera) {
+ return config.cameras[selectedCamera];
+ }
+ }, [config, selectedCamera]);
+
+ useEffect(() => {
+ if (cameraConfig) {
+ setMotionSettings({
+ threshold: cameraConfig.motion.threshold,
+ contour_area: cameraConfig.motion.contour_area,
+ improve_contrast: cameraConfig.motion.improve_contrast,
+ });
+ setOrigMotionSettings({
+ threshold: cameraConfig.motion.threshold,
+ contour_area: cameraConfig.motion.contour_area,
+ improve_contrast: cameraConfig.motion.improve_contrast,
+ });
+ }
+ // we know that these deps are correct
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedCamera]);
+
+ useEffect(() => {
+ if (!motionSettings.threshold) return;
+
+ sendMotionThreshold(motionSettings.threshold);
+ }, [motionSettings.threshold, sendMotionThreshold]);
+
+ useEffect(() => {
+ if (!motionSettings.contour_area) return;
+
+ sendMotionContourArea(motionSettings.contour_area);
+ }, [motionSettings.contour_area, sendMotionContourArea]);
+
+ useEffect(() => {
+ if (motionSettings.improve_contrast === undefined) return;
+
+ sendImproveContrast(motionSettings.improve_contrast ? "ON" : "OFF");
+ }, [motionSettings.improve_contrast, sendImproveContrast]);
+
+ const handleMotionConfigChange = (newConfig: Partial) => {
+ setMotionSettings((prevConfig) => ({ ...prevConfig, ...newConfig }));
+ setUnsavedChanges(true);
+ setChangedValue(true);
+ };
+
+ const saveToConfig = useCallback(async () => {
+ setIsLoading(true);
+
+ axios
+ .put(
+ `config/set?cameras.${selectedCamera}.motion.threshold=${motionSettings.threshold}&cameras.${selectedCamera}.motion.contour_area=${motionSettings.contour_area}&cameras.${selectedCamera}.motion.improve_contrast=${motionSettings.improve_contrast}`,
+ { requires_restart: 0 },
+ )
+ .then((res) => {
+ if (res.status === 200) {
+ toast.success("Motion settings have been saved.", {
+ position: "top-center",
+ });
+ setChangedValue(false);
+ updateConfig();
+ } else {
+ toast.error(`Failed to save config changes: ${res.statusText}`, {
+ position: "top-center",
+ });
+ }
+ })
+ .catch((error) => {
+ toast.error(
+ `Failed to save config changes: ${error.response.data.message}`,
+ { position: "top-center" },
+ );
+ })
+ .finally(() => {
+ setIsLoading(false);
+ });
+ }, [
+ updateConfig,
+ motionSettings.threshold,
+ motionSettings.contour_area,
+ motionSettings.improve_contrast,
+ selectedCamera,
+ ]);
+
+ const onCancel = useCallback(() => {
+ setMotionSettings(origMotionSettings);
+ setChangedValue(false);
+ }, [origMotionSettings]);
+
+ if (!cameraConfig && !selectedCamera) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ Motion Detection Tuner
+
+
+
+ Frigate uses motion detection as a first line check to see if there
+ is anything happening in the frame worth checking with object
+ detection.
+
+
+
+
+ Read the Motion Tuning Guide{" "}
+
+
+
+
+
+
+
+
+
+ Threshold
+
+
+
+ The threshold value dictates how much of a change in a pixel's
+ luminance is required to be considered motion.{" "}
+ Default: 30
+
+
+
+
+
{
+ handleMotionConfigChange({ threshold: value[0] });
+ }}
+ />
+
+ {motionSettings.threshold}
+
+
+
+
+
+
+ Contour Area
+
+
+
+ The contour area value is used to decide which groups of
+ changed pixels qualify as motion. Default: 10
+
+
+
+
+
{
+ handleMotionConfigChange({ contour_area: value[0] });
+ }}
+ />
+
+ {motionSettings.contour_area}
+
+
+
+
+
+
+
Improve Contrast
+
+ Improve contrast for darker scenes. Default: ON
+
+
+
{
+ handleMotionConfigChange({ improve_contrast: isChecked });
+ }}
+ />
+
+
+
+
+
+ Reset
+
+
+ {isLoading ? (
+
+ ) : (
+ "Save"
+ )}
+
+
+
+
+
+ {cameraConfig ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/web/src/components/settings/ObjectMaskEditPane.tsx b/web/src/components/settings/ObjectMaskEditPane.tsx
new file mode 100644
index 000000000..ae755c48f
--- /dev/null
+++ b/web/src/components/settings/ObjectMaskEditPane.tsx
@@ -0,0 +1,409 @@
+import Heading from "../ui/heading";
+import { Separator } from "../ui/separator";
+import { Button } from "@/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { useCallback, useMemo } from "react";
+import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig";
+import useSWR from "swr";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { ObjectMaskFormValuesType, Polygon } from "@/types/canvas";
+import PolygonEditControls from "./PolygonEditControls";
+import { FaCheckCircle } from "react-icons/fa";
+import {
+ flattenPoints,
+ interpolatePoints,
+ parseCoordinates,
+} from "@/utils/canvasUtil";
+import axios from "axios";
+import { toast } from "sonner";
+import { Toaster } from "../ui/sonner";
+import ActivityIndicator from "../indicators/activity-indicator";
+
+type ObjectMaskEditPaneProps = {
+ polygons?: Polygon[];
+ setPolygons: React.Dispatch>;
+ activePolygonIndex?: number;
+ scaledWidth?: number;
+ scaledHeight?: number;
+ isLoading: boolean;
+ setIsLoading: React.Dispatch>;
+ onSave?: () => void;
+ onCancel?: () => void;
+};
+
+export default function ObjectMaskEditPane({
+ polygons,
+ setPolygons,
+ activePolygonIndex,
+ scaledWidth,
+ scaledHeight,
+ isLoading,
+ setIsLoading,
+ onSave,
+ onCancel,
+}: ObjectMaskEditPaneProps) {
+ const { data: config, mutate: updateConfig } =
+ useSWR("config");
+
+ const polygon = useMemo(() => {
+ if (polygons && activePolygonIndex !== undefined) {
+ return polygons[activePolygonIndex];
+ } else {
+ return null;
+ }
+ }, [polygons, activePolygonIndex]);
+
+ const cameraConfig = useMemo(() => {
+ if (polygon?.camera && config) {
+ return config.cameras[polygon.camera];
+ }
+ }, [polygon, config]);
+
+ const defaultName = useMemo(() => {
+ if (!polygons) {
+ return;
+ }
+
+ const count = polygons.filter((poly) => poly.type == "object_mask").length;
+
+ let objectType = "";
+ const objects = polygon?.objects[0];
+ if (objects === undefined) {
+ objectType = "all objects";
+ } else {
+ objectType = objects;
+ }
+
+ return `Object Mask ${count + 1} (${objectType})`;
+ }, [polygons, polygon]);
+
+ const formSchema = z
+ .object({
+ objects: z.string(),
+ polygon: z.object({ isFinished: z.boolean(), name: z.string() }),
+ })
+ .refine(() => polygon?.isFinished === true, {
+ message: "The polygon drawing must be finished before saving.",
+ path: ["polygon.isFinished"],
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ mode: "onChange",
+ defaultValues: {
+ objects: polygon?.objects[0] ?? "all_labels",
+ polygon: { isFinished: polygon?.isFinished ?? false, name: defaultName },
+ },
+ });
+
+ const saveToConfig = useCallback(
+ async (
+ { objects: form_objects }: ObjectMaskFormValuesType, // values submitted via the form
+ ) => {
+ if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) {
+ return;
+ }
+
+ const coordinates = flattenPoints(
+ interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
+ ).join(",");
+
+ let queryString = "";
+ let configObject;
+ let createFilter = false;
+ let globalMask = false;
+ let filteredMask = [coordinates];
+ const editingMask = polygon.name.length > 0;
+
+ // global mask on camera for all objects
+ if (form_objects == "all_labels") {
+ configObject = cameraConfig.objects.mask;
+ globalMask = true;
+ } else {
+ if (
+ cameraConfig.objects.filters[form_objects] &&
+ cameraConfig.objects.filters[form_objects].mask !== null
+ ) {
+ configObject = cameraConfig.objects.filters[form_objects].mask;
+ } else {
+ createFilter = true;
+ }
+ }
+
+ if (!createFilter) {
+ let index = Array.isArray(configObject)
+ ? configObject.length
+ : configObject
+ ? 1
+ : 0;
+
+ if (editingMask) {
+ index = polygon.typeIndex;
+ }
+
+ // editing existing mask, not creating a new one
+ if (editingMask) {
+ index = polygon.typeIndex;
+ }
+
+ filteredMask = (
+ Array.isArray(configObject) ? configObject : [configObject as string]
+ ).filter((_, currentIndex) => currentIndex !== index);
+
+ filteredMask.splice(index, 0, coordinates);
+ }
+
+ queryString = filteredMask
+ .map((pointsArray) => {
+ const coordinates = flattenPoints(parseCoordinates(pointsArray)).join(
+ ",",
+ );
+ return globalMask
+ ? `cameras.${polygon?.camera}.objects.mask=${coordinates}&`
+ : `cameras.${polygon?.camera}.objects.filters.${form_objects}.mask=${coordinates}&`;
+ })
+ .join("");
+
+ if (!queryString) {
+ return;
+ }
+
+ axios
+ .put(`config/set?${queryString}`, {
+ requires_restart: 0,
+ })
+ .then((res) => {
+ if (res.status === 200) {
+ toast.success(`${polygon.name || "Object Mask"} has been saved.`, {
+ position: "top-center",
+ });
+ updateConfig();
+ } else {
+ toast.error(`Failed to save config changes: ${res.statusText}`, {
+ position: "top-center",
+ });
+ }
+ })
+ .catch((error) => {
+ toast.error(
+ `Failed to save config changes: ${error.response.data.message}`,
+ { position: "top-center" },
+ );
+ })
+ .finally(() => {
+ setIsLoading(false);
+ });
+ },
+ [
+ updateConfig,
+ polygon,
+ scaledWidth,
+ scaledHeight,
+ setIsLoading,
+ cameraConfig,
+ ],
+ );
+
+ function onSubmit(values: z.infer) {
+ if (activePolygonIndex === undefined || !values || !polygons) {
+ return;
+ }
+ setIsLoading(true);
+
+ saveToConfig(values as ObjectMaskFormValuesType);
+ if (onSave) {
+ onSave();
+ }
+ }
+
+ if (!polygon) {
+ return;
+ }
+
+ return (
+ <>
+
+
+ {polygon.name.length ? "Edit" : "New"} Object Mask
+
+
+
+ Object filter masks are used to filter out false positives for a given
+ object type based on location.
+
+
+
+ {polygons && activePolygonIndex !== undefined && (
+
+
+ {polygons[activePolygonIndex].points.length}{" "}
+ {polygons[activePolygonIndex].points.length > 1 ||
+ polygons[activePolygonIndex].points.length == 0
+ ? "points"
+ : "point"}
+ {polygons[activePolygonIndex].isFinished && (
+
+ )}
+
+
+
+ )}
+
+ Click to draw a polygon on the image.
+
+
+
+
+
+
+ >
+ );
+}
+
+type ZoneObjectSelectorProps = {
+ camera: string;
+};
+
+export function ZoneObjectSelector({ camera }: ZoneObjectSelectorProps) {
+ const { data: config } = useSWR("config");
+
+ const cameraConfig = useMemo(() => {
+ if (config && camera) {
+ return config.cameras[camera];
+ }
+ }, [config, camera]);
+
+ const allLabels = useMemo(() => {
+ if (!config || !cameraConfig) {
+ return [];
+ }
+
+ const labels = new Set();
+
+ Object.values(config.cameras).forEach((camera) => {
+ camera.objects.track.forEach((label) => {
+ if (!ATTRIBUTE_LABELS.includes(label)) {
+ labels.add(label);
+ }
+ });
+ });
+
+ cameraConfig.objects.track.forEach((label) => {
+ if (!ATTRIBUTE_LABELS.includes(label)) {
+ labels.add(label);
+ }
+ });
+
+ return [...labels].sort();
+ }, [config, cameraConfig]);
+
+ return (
+ <>
+
+ All object types
+
+ {allLabels.map((item) => (
+
+ {item.replaceAll("_", " ").charAt(0).toUpperCase() + item.slice(1)}
+
+ ))}
+
+ >
+ );
+}
diff --git a/web/src/components/settings/ObjectSettings.tsx b/web/src/components/settings/ObjectSettings.tsx
new file mode 100644
index 000000000..4b45c1fa4
--- /dev/null
+++ b/web/src/components/settings/ObjectSettings.tsx
@@ -0,0 +1,31 @@
+import { useMemo } from "react";
+import DebugCameraImage from "../camera/DebugCameraImage";
+import { FrigateConfig } from "@/types/frigateConfig";
+import useSWR from "swr";
+import ActivityIndicator from "../indicators/activity-indicator";
+
+type ObjectSettingsProps = {
+ selectedCamera?: string;
+};
+
+export default function ObjectSettings({
+ selectedCamera,
+}: ObjectSettingsProps) {
+ const { data: config } = useSWR("config");
+
+ const cameraConfig = useMemo(() => {
+ if (config && selectedCamera) {
+ return config.cameras[selectedCamera];
+ }
+ }, [config, selectedCamera]);
+
+ if (!cameraConfig) {
+ return ;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/web/src/components/settings/PolygonCanvas.tsx b/web/src/components/settings/PolygonCanvas.tsx
new file mode 100644
index 000000000..22c23a226
--- /dev/null
+++ b/web/src/components/settings/PolygonCanvas.tsx
@@ -0,0 +1,384 @@
+import React, { useMemo, useRef, useState, useEffect } from "react";
+import PolygonDrawer from "./PolygonDrawer";
+import { Stage, Layer, Image } from "react-konva";
+import Konva from "konva";
+import type { KonvaEventObject } from "konva/lib/Node";
+import { Polygon, PolygonType } from "@/types/canvas";
+import { useApiHost } from "@/api";
+
+type PolygonCanvasProps = {
+ camera: string;
+ width: number;
+ height: number;
+ polygons: Polygon[];
+ setPolygons: React.Dispatch>;
+ activePolygonIndex: number | undefined;
+ hoveredPolygonIndex: number | null;
+ selectedZoneMask: PolygonType[] | undefined;
+};
+
+export function PolygonCanvas({
+ camera,
+ width,
+ height,
+ polygons,
+ setPolygons,
+ activePolygonIndex,
+ hoveredPolygonIndex,
+ selectedZoneMask,
+}: PolygonCanvasProps) {
+ const [image, setImage] = useState();
+ const imageRef = useRef(null);
+ const stageRef = useRef(null);
+ const apiHost = useApiHost();
+
+ const videoElement = useMemo(() => {
+ if (camera && width && height) {
+ const element = new window.Image();
+ element.width = width;
+ element.height = height;
+ element.src = `${apiHost}api/${camera}/latest.jpg`;
+ return element;
+ }
+ }, [camera, width, height, apiHost]);
+
+ useEffect(() => {
+ if (!videoElement) {
+ return;
+ }
+ const onload = function () {
+ setImage(videoElement);
+ };
+ videoElement.addEventListener("load", onload);
+ return () => {
+ videoElement.removeEventListener("load", onload);
+ };
+ }, [videoElement]);
+
+ const getMousePos = (stage: Konva.Stage) => {
+ return [stage.getPointerPosition()!.x, stage.getPointerPosition()!.y];
+ };
+
+ const addPointToPolygon = (polygon: Polygon, newPoint: number[]) => {
+ const points = polygon.points;
+ const [newPointX, newPointY] = newPoint;
+ const updatedPoints = [...points];
+
+ for (let i = 0; i < points.length; i++) {
+ const [x1, y1] = points[i];
+ const [x2, y2] = i === points.length - 1 ? points[0] : points[i + 1];
+
+ if (
+ (x1 <= newPointX && newPointX <= x2) ||
+ (x2 <= newPointX && newPointX <= x1)
+ ) {
+ if (
+ (y1 <= newPointY && newPointY <= y2) ||
+ (y2 <= newPointY && newPointY <= y1)
+ ) {
+ const insertIndex = i + 1;
+ updatedPoints.splice(insertIndex, 0, [newPointX, newPointY]);
+ break;
+ }
+ }
+ }
+
+ return updatedPoints;
+ };
+
+ const isPointNearLineSegment = (
+ polygon: Polygon,
+ mousePos: number[],
+ radius = 10,
+ ) => {
+ const points = polygon.points;
+ const [x, y] = mousePos;
+
+ for (let i = 0; i < points.length; i++) {
+ const [x1, y1] = points[i];
+ const [x2, y2] = i === points.length - 1 ? points[0] : points[i + 1];
+
+ const crossProduct = (x - x1) * (x2 - x1) + (y - y1) * (y2 - y1);
+ if (crossProduct > 0) {
+ const lengthSquared = (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1);
+ const dot = (x - x1) * (x2 - x1) + (y - y1) * (y2 - y1);
+ if (dot < 0 || dot > lengthSquared) {
+ continue;
+ }
+ const lineSegmentDistance = Math.abs(
+ ((y2 - y1) * x - (x2 - x1) * y + x2 * y1 - y2 * x1) /
+ Math.sqrt(Math.pow(y2 - y1, 2) + Math.pow(x2 - x1, 2)),
+ );
+ if (lineSegmentDistance <= radius) {
+ const midPointX = (x1 + x2) / 2;
+ const midPointY = (y1 + y2) / 2;
+ return [midPointX, midPointY];
+ }
+ }
+ }
+
+ return null;
+ };
+
+ const isMouseOverFirstPoint = (polygon: Polygon, mousePos: number[]) => {
+ if (!polygon || !polygon.points || polygon.points.length < 1) {
+ return false;
+ }
+ const [firstPoint] = polygon.points;
+ const distance = Math.hypot(
+ mousePos[0] - firstPoint[0],
+ mousePos[1] - firstPoint[1],
+ );
+ return distance < 10;
+ };
+
+ const isMouseOverAnyPoint = (polygon: Polygon, mousePos: number[]) => {
+ if (!polygon || !polygon.points || polygon.points.length === 0) {
+ return false;
+ }
+
+ for (let i = 1; i < polygon.points.length; i++) {
+ const point = polygon.points[i];
+ const distance = Math.hypot(
+ mousePos[0] - point[0],
+ mousePos[1] - point[1],
+ );
+ if (distance < 10) {
+ return true;
+ }
+ }
+
+ return false;
+ };
+
+ const handleMouseDown = (e: KonvaEventObject) => {
+ if (activePolygonIndex === undefined || !polygons) {
+ return;
+ }
+
+ const updatedPolygons = [...polygons];
+ const activePolygon = updatedPolygons[activePolygonIndex];
+ const stage = e.target.getStage()!;
+ const mousePos = getMousePos(stage);
+
+ if (
+ activePolygon.points.length >= 3 &&
+ isMouseOverFirstPoint(activePolygon, mousePos)
+ ) {
+ // Close the polygon
+ updatedPolygons[activePolygonIndex] = {
+ ...activePolygon,
+ isFinished: true,
+ };
+ setPolygons(updatedPolygons);
+ } else {
+ if (
+ !activePolygon.isFinished &&
+ !isMouseOverAnyPoint(activePolygon, mousePos)
+ ) {
+ let updatedPoints;
+
+ if (isPointNearLineSegment(activePolygon, mousePos)) {
+ // we've clicked near a line segment, so add a new point in the right position
+ updatedPoints = addPointToPolygon(activePolygon, mousePos);
+ } else {
+ // Add a new point to the active polygon
+ updatedPoints = [...activePolygon.points, mousePos];
+ }
+ updatedPolygons[activePolygonIndex] = {
+ ...activePolygon,
+ points: updatedPoints,
+ };
+ setPolygons(updatedPolygons);
+ }
+ }
+ // }
+ };
+
+ const handleMouseOverStartPoint = (
+ e: KonvaEventObject,
+ ) => {
+ if (activePolygonIndex === undefined || !polygons) {
+ return;
+ }
+
+ const activePolygon = polygons[activePolygonIndex];
+ if (!activePolygon.isFinished && activePolygon.points.length >= 3) {
+ e.target.getStage()!.container().style.cursor = "default";
+ e.currentTarget.scale({ x: 2, y: 2 });
+ }
+ };
+
+ const handleMouseOutStartPoint = (
+ e: KonvaEventObject,
+ ) => {
+ e.currentTarget.scale({ x: 1, y: 1 });
+
+ if (activePolygonIndex === undefined || !polygons) {
+ return;
+ }
+
+ const activePolygon = polygons[activePolygonIndex];
+ if (
+ (!activePolygon.isFinished && activePolygon.points.length >= 3) ||
+ activePolygon.isFinished
+ ) {
+ e.currentTarget.scale({ x: 1, y: 1 });
+ }
+ };
+
+ const handleMouseOverAnyPoint = (
+ e: KonvaEventObject,
+ ) => {
+ if (!polygons) {
+ return;
+ }
+ e.target.getStage()!.container().style.cursor = "move";
+ };
+
+ const handleMouseOutAnyPoint = (
+ e: KonvaEventObject,
+ ) => {
+ if (activePolygonIndex === undefined || !polygons) {
+ return;
+ }
+ const activePolygon = polygons[activePolygonIndex];
+ if (activePolygon.isFinished) {
+ e.target.getStage()!.container().style.cursor = "default";
+ } else {
+ e.target.getStage()!.container().style.cursor = "crosshair";
+ }
+ };
+
+ const handlePointDragMove = (
+ e: KonvaEventObject,
+ ) => {
+ if (activePolygonIndex === undefined || !polygons) {
+ return;
+ }
+
+ const updatedPolygons = [...polygons];
+ const activePolygon = updatedPolygons[activePolygonIndex];
+ const stage = e.target.getStage();
+ if (stage) {
+ const index = e.target.index - 1;
+ const pos = [e.target._lastPos!.x, e.target._lastPos!.y];
+ if (pos[0] < 0) pos[0] = 0;
+ if (pos[1] < 0) pos[1] = 0;
+ if (pos[0] > stage.width()) pos[0] = stage.width();
+ if (pos[1] > stage.height()) pos[1] = stage.height();
+ updatedPolygons[activePolygonIndex] = {
+ ...activePolygon,
+ points: [
+ ...activePolygon.points.slice(0, index),
+ pos,
+ ...activePolygon.points.slice(index + 1),
+ ],
+ };
+ setPolygons(updatedPolygons);
+ }
+ };
+
+ const handleGroupDragEnd = (e: KonvaEventObject) => {
+ if (activePolygonIndex !== undefined && e.target.name() === "polygon") {
+ const updatedPolygons = [...polygons];
+ const activePolygon = updatedPolygons[activePolygonIndex];
+ const result: number[][] = [];
+ activePolygon.points.map((point: number[]) =>
+ result.push([point[0] + e.target.x(), point[1] + e.target.y()]),
+ );
+ e.target.position({ x: 0, y: 0 });
+ updatedPolygons[activePolygonIndex] = {
+ ...activePolygon,
+ points: result,
+ };
+ setPolygons(updatedPolygons);
+ }
+ };
+
+ const handleStageMouseOver = (
+ e: Konva.KonvaEventObject,
+ ) => {
+ if (activePolygonIndex === undefined || !polygons) {
+ return;
+ }
+
+ const updatedPolygons = [...polygons];
+ const activePolygon = updatedPolygons[activePolygonIndex];
+ const stage = e.target.getStage()!;
+ const mousePos = getMousePos(stage);
+
+ if (
+ activePolygon.isFinished ||
+ isMouseOverAnyPoint(activePolygon, mousePos) ||
+ isMouseOverFirstPoint(activePolygon, mousePos)
+ )
+ return;
+
+ e.target.getStage()!.container().style.cursor = "crosshair";
+ };
+
+ return (
+
+
+
+ {polygons?.map(
+ (polygon, index) =>
+ (selectedZoneMask === undefined ||
+ selectedZoneMask.includes(polygon.type)) &&
+ index !== activePolygonIndex && (
+
+ ),
+ )}
+ {activePolygonIndex !== undefined &&
+ polygons?.[activePolygonIndex] &&
+ (selectedZoneMask === undefined ||
+ selectedZoneMask.includes(polygons[activePolygonIndex].type)) && (
+
+ )}
+
+
+ );
+}
+
+export default PolygonCanvas;
diff --git a/web/src/components/settings/PolygonDrawer.tsx b/web/src/components/settings/PolygonDrawer.tsx
new file mode 100644
index 000000000..99d05888e
--- /dev/null
+++ b/web/src/components/settings/PolygonDrawer.tsx
@@ -0,0 +1,172 @@
+import { useCallback, useMemo, useRef, useState } from "react";
+import { Line, Circle, Group } from "react-konva";
+import {
+ minMax,
+ toRGBColorString,
+ dragBoundFunc,
+ flattenPoints,
+} from "@/utils/canvasUtil";
+import type { KonvaEventObject } from "konva/lib/Node";
+import Konva from "konva";
+import { Vector2d } from "konva/lib/types";
+
+type PolygonDrawerProps = {
+ points: number[][];
+ isActive: boolean;
+ isHovered: boolean;
+ isFinished: boolean;
+ color: number[];
+ handlePointDragMove: (e: KonvaEventObject) => void;
+ handleGroupDragEnd: (e: KonvaEventObject) => void;
+ handleMouseOverStartPoint: (
+ e: KonvaEventObject,
+ ) => void;
+ handleMouseOutStartPoint: (
+ e: KonvaEventObject,
+ ) => void;
+ handleMouseOverAnyPoint: (
+ e: KonvaEventObject,
+ ) => void;
+ handleMouseOutAnyPoint: (
+ e: KonvaEventObject,
+ ) => void;
+};
+
+export default function PolygonDrawer({
+ points,
+ isActive,
+ isHovered,
+ isFinished,
+ color,
+ handlePointDragMove,
+ handleGroupDragEnd,
+ handleMouseOverStartPoint,
+ handleMouseOutStartPoint,
+ handleMouseOverAnyPoint,
+ handleMouseOutAnyPoint,
+}: PolygonDrawerProps) {
+ const vertexRadius = 6;
+ const flattenedPoints = useMemo(() => flattenPoints(points), [points]);
+ const [stage, setStage] = useState();
+ const [minMaxX, setMinMaxX] = useState([0, 0]);
+ const [minMaxY, setMinMaxY] = useState([0, 0]);
+ const groupRef = useRef(null);
+
+ const handleGroupMouseOver = (
+ e: Konva.KonvaEventObject,
+ ) => {
+ if (!isFinished) return;
+ e.target.getStage()!.container().style.cursor = "move";
+ setStage(e.target.getStage()!);
+ };
+
+ const handleGroupMouseOut = (
+ e: Konva.KonvaEventObject,
+ ) => {
+ if (!e.target || !isFinished) return;
+ e.target.getStage()!.container().style.cursor = "default";
+ };
+
+ const handleGroupDragStart = () => {
+ const arrX = points.map((p) => p[0]);
+ const arrY = points.map((p) => p[1]);
+ setMinMaxX(minMax(arrX));
+ setMinMaxY(minMax(arrY));
+ };
+
+ const groupDragBound = (pos: Vector2d) => {
+ if (!stage) {
+ return pos;
+ }
+
+ let { x, y } = pos;
+ const sw = stage.width();
+ const sh = stage.height();
+
+ if (minMaxY[0] + y < 0) y = -1 * minMaxY[0];
+ if (minMaxX[0] + x < 0) x = -1 * minMaxX[0];
+ if (minMaxY[1] + y > sh) y = sh - minMaxY[1];
+ if (minMaxX[1] + x > sw) x = sw - minMaxX[1];
+
+ return { x, y };
+ };
+
+ const colorString = useCallback(
+ (darkened: boolean) => {
+ return toRGBColorString(color, darkened);
+ },
+ [color],
+ );
+
+ return (
+
+
+ {points.map((point, index) => {
+ if (!isActive) {
+ return;
+ }
+ const x = point[0];
+ const y = point[1];
+ const startPointAttr =
+ index === 0
+ ? {
+ hitStrokeWidth: 12,
+ onMouseOver: handleMouseOverStartPoint,
+ onMouseOut: handleMouseOutStartPoint,
+ }
+ : null;
+ const otherPointsAttr =
+ index !== 0
+ ? {
+ onMouseOver: handleMouseOverAnyPoint,
+ onMouseOut: handleMouseOutAnyPoint,
+ }
+ : null;
+
+ return (
+ {
+ if (stage) {
+ return dragBoundFunc(
+ stage.width(),
+ stage.height(),
+ vertexRadius,
+ pos,
+ );
+ } else {
+ return pos;
+ }
+ }}
+ {...startPointAttr}
+ {...otherPointsAttr}
+ />
+ );
+ })}
+
+ );
+}
diff --git a/web/src/components/settings/PolygonEditControls.tsx b/web/src/components/settings/PolygonEditControls.tsx
new file mode 100644
index 000000000..ba979fc82
--- /dev/null
+++ b/web/src/components/settings/PolygonEditControls.tsx
@@ -0,0 +1,81 @@
+import { Polygon } from "@/types/canvas";
+import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
+import { MdOutlineRestartAlt, MdUndo } from "react-icons/md";
+import { Button } from "../ui/button";
+
+type PolygonEditControlsProps = {
+ polygons: Polygon[];
+ setPolygons: React.Dispatch>;
+ activePolygonIndex: number | undefined;
+};
+
+export default function PolygonEditControls({
+ polygons,
+ setPolygons,
+ activePolygonIndex,
+}: PolygonEditControlsProps) {
+ const undo = () => {
+ if (activePolygonIndex === undefined || !polygons) {
+ return;
+ }
+
+ const updatedPolygons = [...polygons];
+ const activePolygon = updatedPolygons[activePolygonIndex];
+ updatedPolygons[activePolygonIndex] = {
+ ...activePolygon,
+ points: [...activePolygon.points.slice(0, -1)],
+ isFinished: false,
+ };
+ setPolygons(updatedPolygons);
+ };
+
+ const reset = () => {
+ if (activePolygonIndex === undefined || !polygons) {
+ return;
+ }
+
+ const updatedPolygons = [...polygons];
+ const activePolygon = updatedPolygons[activePolygonIndex];
+ updatedPolygons[activePolygonIndex] = {
+ ...activePolygon,
+ points: [],
+ isFinished: false,
+ };
+ setPolygons(updatedPolygons);
+ };
+
+ if (activePolygonIndex === undefined || !polygons) {
+ return;
+ }
+
+ return (
+
+
+
+
+
+
+
+ Undo
+
+
+
+
+
+
+
+ Reset
+
+
+ );
+}
diff --git a/web/src/components/settings/PolygonItem.tsx b/web/src/components/settings/PolygonItem.tsx
new file mode 100644
index 000000000..9d8972341
--- /dev/null
+++ b/web/src/components/settings/PolygonItem.tsx
@@ -0,0 +1,334 @@
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "../ui/alert-dialog";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
+import { LuCopy, LuPencil } from "react-icons/lu";
+import { FaDrawPolygon, FaObjectGroup } from "react-icons/fa";
+import { BsPersonBoundingBox } from "react-icons/bs";
+import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi";
+import { isMobile } from "react-device-detect";
+import {
+ flattenPoints,
+ parseCoordinates,
+ toRGBColorString,
+} from "@/utils/canvasUtil";
+import { Polygon, PolygonType } from "@/types/canvas";
+import { useCallback, useMemo, useState } from "react";
+import axios from "axios";
+import { Toaster } from "@/components/ui/sonner";
+import { toast } from "sonner";
+import useSWR from "swr";
+import { FrigateConfig } from "@/types/frigateConfig";
+import { reviewQueries } from "@/utils/zoneEdutUtil";
+import IconWrapper from "../ui/icon-wrapper";
+
+type PolygonItemProps = {
+ polygon: Polygon;
+ index: number;
+ hoveredPolygonIndex: number | null;
+ setHoveredPolygonIndex: (index: number | null) => void;
+ setActivePolygonIndex: (index: number | undefined) => void;
+ setEditPane: (type: PolygonType) => void;
+ handleCopyCoordinates: (index: number) => void;
+};
+
+export default function PolygonItem({
+ polygon,
+ index,
+ hoveredPolygonIndex,
+ setHoveredPolygonIndex,
+ setActivePolygonIndex,
+ setEditPane,
+ handleCopyCoordinates,
+}: PolygonItemProps) {
+ const { data: config, mutate: updateConfig } =
+ useSWR("config");
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const cameraConfig = useMemo(() => {
+ if (polygon?.camera && config) {
+ return config.cameras[polygon.camera];
+ }
+ }, [polygon, config]);
+
+ const polygonTypeIcons = {
+ zone: FaDrawPolygon,
+ motion_mask: FaObjectGroup,
+ object_mask: BsPersonBoundingBox,
+ };
+
+ const PolygonItemIcon = polygon ? polygonTypeIcons[polygon.type] : undefined;
+
+ const saveToConfig = useCallback(
+ async (polygon: Polygon) => {
+ if (!polygon || !cameraConfig) {
+ return;
+ }
+ let url = "";
+ if (polygon.type == "zone") {
+ const { alertQueries, detectionQueries } = reviewQueries(
+ polygon.name,
+ false,
+ false,
+ polygon.camera,
+ cameraConfig?.review.alerts.required_zones || [],
+ cameraConfig?.review.detections.required_zones || [],
+ );
+ url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`;
+ }
+ if (polygon.type == "motion_mask") {
+ const filteredMask = (
+ Array.isArray(cameraConfig.motion.mask)
+ ? cameraConfig.motion.mask
+ : [cameraConfig.motion.mask]
+ ).filter((_, currentIndex) => currentIndex !== polygon.typeIndex);
+
+ url = filteredMask
+ .map((pointsArray) => {
+ const coordinates = flattenPoints(
+ parseCoordinates(pointsArray),
+ ).join(",");
+ return `cameras.${polygon?.camera}.motion.mask=${coordinates}&`;
+ })
+ .join("");
+
+ if (!url) {
+ // deleting last mask
+ url = `cameras.${polygon?.camera}.motion.mask&`;
+ }
+ }
+
+ if (polygon.type == "object_mask") {
+ let configObject;
+ let globalMask = false;
+
+ // global mask on camera for all objects
+ if (!polygon.objects.length) {
+ configObject = cameraConfig.objects.mask;
+ globalMask = true;
+ } else {
+ configObject = cameraConfig.objects.filters[polygon.objects[0]].mask;
+ }
+
+ if (!configObject) {
+ return;
+ }
+
+ const globalObjectMasksArray = Array.isArray(cameraConfig.objects.mask)
+ ? cameraConfig.objects.mask
+ : cameraConfig.objects.mask
+ ? [cameraConfig.objects.mask]
+ : [];
+
+ let filteredMask;
+ if (globalMask) {
+ filteredMask = (
+ Array.isArray(configObject) ? configObject : [configObject]
+ ).filter((_, currentIndex) => currentIndex !== polygon.typeIndex);
+ } else {
+ filteredMask = (
+ Array.isArray(configObject) ? configObject : [configObject]
+ )
+ .filter((mask) => !globalObjectMasksArray.includes(mask))
+ .filter((_, currentIndex) => currentIndex !== polygon.typeIndex);
+ }
+
+ url = filteredMask
+ .map((pointsArray) => {
+ const coordinates = flattenPoints(
+ parseCoordinates(pointsArray),
+ ).join(",");
+ return globalMask
+ ? `cameras.${polygon?.camera}.objects.mask=${coordinates}&`
+ : `cameras.${polygon?.camera}.objects.filters.${polygon.objects[0]}.mask=${coordinates}&`;
+ })
+ .join("");
+
+ if (!url) {
+ // deleting last mask
+ url = globalMask
+ ? `cameras.${polygon?.camera}.objects.mask&`
+ : `cameras.${polygon?.camera}.objects.filters.${polygon.objects[0]}.mask`;
+ }
+ }
+
+ setIsLoading(true);
+
+ await axios
+ .put(`config/set?${url}`, { requires_restart: 0 })
+ .then((res) => {
+ if (res.status === 200) {
+ toast.success(`${polygon?.name} has been deleted.`, {
+ position: "top-center",
+ });
+ updateConfig();
+ } else {
+ toast.error(`Failed to save config changes: ${res.statusText}`, {
+ position: "top-center",
+ });
+ }
+ })
+ .catch((error) => {
+ toast.error(
+ `Failed to save config changes: ${error.response.data.message}`,
+ { position: "top-center" },
+ );
+ })
+ .finally(() => {
+ setIsLoading(false);
+ });
+ },
+ [updateConfig, cameraConfig],
+ );
+
+ const handleDelete = () => {
+ setActivePolygonIndex(undefined);
+ saveToConfig(polygon);
+ };
+
+ return (
+ <>
+
+
+ setHoveredPolygonIndex(index)}
+ onMouseLeave={() => setHoveredPolygonIndex(null)}
+ style={{
+ backgroundColor:
+ hoveredPolygonIndex === index
+ ? toRGBColorString(polygon.color, false)
+ : "",
+ }}
+ >
+
+ {PolygonItemIcon && (
+
+ )}
+
{polygon.name}
+
+
setDeleteDialogOpen(!deleteDialogOpen)}
+ >
+
+
+ Confirm Delete
+
+
+ Are you sure you want to delete the{" "}
+ {polygon.type.replace("_", " ")} {polygon.name} ?
+
+
+ Cancel
+
+ Delete
+
+
+
+
+
+ {isMobile && (
+ <>
+
+
+
+
+
+ {
+ setActivePolygonIndex(index);
+ setEditPane(polygon.type);
+ }}
+ >
+ Edit
+
+ handleCopyCoordinates(index)}>
+ Copy
+
+ setDeleteDialogOpen(true)}
+ >
+ Delete
+
+
+
+ >
+ )}
+ {!isMobile && hoveredPolygonIndex === index && (
+
+
+
+ {
+ setActivePolygonIndex(index);
+ setEditPane(polygon.type);
+ }}
+ />
+
+ Edit
+
+
+
+
+ handleCopyCoordinates(index)}
+ />
+
+ Copy coordinates
+
+
+
+
+ !isLoading && setDeleteDialogOpen(true)}
+ />
+
+ Delete
+
+
+ )}
+
+ >
+ );
+}
diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx
new file mode 100644
index 000000000..803d172d8
--- /dev/null
+++ b/web/src/components/settings/ZoneEditPane.tsx
@@ -0,0 +1,649 @@
+import Heading from "../ui/heading";
+import { Separator } from "../ui/separator";
+import { Button } from "@/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig";
+import useSWR from "swr";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { ZoneFormValuesType, Polygon } from "@/types/canvas";
+import { reviewQueries } from "@/utils/zoneEdutUtil";
+import { Switch } from "../ui/switch";
+import { Label } from "../ui/label";
+import PolygonEditControls from "./PolygonEditControls";
+import { FaCheckCircle } from "react-icons/fa";
+import axios from "axios";
+import { Toaster } from "@/components/ui/sonner";
+import { toast } from "sonner";
+import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
+import ActivityIndicator from "../indicators/activity-indicator";
+
+type ZoneEditPaneProps = {
+ polygons?: Polygon[];
+ setPolygons: React.Dispatch>;
+ activePolygonIndex?: number;
+ scaledWidth?: number;
+ scaledHeight?: number;
+ isLoading: boolean;
+ setIsLoading: React.Dispatch>;
+ onSave?: () => void;
+ onCancel?: () => void;
+};
+
+export default function ZoneEditPane({
+ polygons,
+ setPolygons,
+ activePolygonIndex,
+ scaledWidth,
+ scaledHeight,
+ isLoading,
+ setIsLoading,
+ onSave,
+ onCancel,
+}: ZoneEditPaneProps) {
+ const { data: config, mutate: updateConfig } =
+ useSWR("config");
+
+ const cameras = useMemo(() => {
+ if (!config) {
+ return [];
+ }
+
+ return Object.values(config.cameras)
+ .filter((conf) => conf.ui.dashboard && conf.enabled)
+ .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
+ }, [config]);
+
+ const polygon = useMemo(() => {
+ if (polygons && activePolygonIndex !== undefined) {
+ return polygons[activePolygonIndex];
+ } else {
+ return null;
+ }
+ }, [polygons, activePolygonIndex]);
+
+ const cameraConfig = useMemo(() => {
+ if (polygon?.camera && config) {
+ return config.cameras[polygon.camera];
+ }
+ }, [polygon, config]);
+
+ const formSchema = z.object({
+ name: z
+ .string()
+ .min(2, {
+ message: "Zone name must be at least 2 characters.",
+ })
+ .transform((val: string) => val.trim().replace(/\s+/g, "_"))
+ .refine(
+ (value: string) => {
+ return !cameras.map((cam) => cam.name).includes(value);
+ },
+ {
+ message: "Zone name must not be the name of a camera.",
+ },
+ )
+ .refine(
+ (value: string) => {
+ const otherPolygonNames =
+ polygons
+ ?.filter((_, index) => index !== activePolygonIndex)
+ .map((polygon) => polygon.name) || [];
+
+ return !otherPolygonNames.includes(value);
+ },
+ {
+ message: "Zone name already exists on this camera.",
+ },
+ ),
+ inertia: z.coerce
+ .number()
+ .min(1, {
+ message: "Inertia must be above 0.",
+ })
+ .or(z.literal("")),
+ loitering_time: z.coerce
+ .number()
+ .min(0, {
+ message: "Loitering time must be greater than or equal to 0.",
+ })
+ .optional()
+ .or(z.literal("")),
+ isFinished: z.boolean().refine(() => polygon?.isFinished === true, {
+ message: "The polygon drawing must be finished before saving.",
+ }),
+ objects: z.array(z.string()).optional(),
+ review_alerts: z.boolean().default(false).optional(),
+ review_detections: z.boolean().default(false).optional(),
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ mode: "onChange",
+ defaultValues: {
+ name: polygon?.name ?? "",
+ inertia:
+ polygon?.camera &&
+ polygon?.name &&
+ config?.cameras[polygon.camera]?.zones[polygon.name]?.inertia,
+ loitering_time:
+ polygon?.camera &&
+ polygon?.name &&
+ config?.cameras[polygon.camera]?.zones[polygon.name]?.loitering_time,
+ isFinished: polygon?.isFinished ?? false,
+ objects: polygon?.objects ?? [],
+ review_alerts:
+ (polygon?.camera &&
+ polygon?.name &&
+ config?.cameras[
+ polygon.camera
+ ]?.review.alerts.required_zones.includes(polygon.name)) ||
+ false,
+ review_detections:
+ (polygon?.camera &&
+ polygon?.name &&
+ config?.cameras[
+ polygon.camera
+ ]?.review.detections.required_zones.includes(polygon.name)) ||
+ false,
+ },
+ });
+
+ const saveToConfig = useCallback(
+ async (
+ {
+ name: zoneName,
+ inertia,
+ loitering_time,
+ objects: form_objects,
+ review_alerts,
+ review_detections,
+ }: ZoneFormValuesType, // values submitted via the form
+ objects: string[],
+ ) => {
+ if (!scaledWidth || !scaledHeight || !polygon) {
+ return;
+ }
+ let mutatedConfig = config;
+
+ const renamingZone = zoneName != polygon.name && polygon.name != "";
+
+ if (renamingZone) {
+ // rename - delete old zone and replace with new
+ const {
+ alertQueries: renameAlertQueries,
+ detectionQueries: renameDetectionQueries,
+ } = reviewQueries(
+ polygon.name,
+ false,
+ false,
+ polygon.camera,
+ cameraConfig?.review.alerts.required_zones || [],
+ cameraConfig?.review.detections.required_zones || [],
+ );
+
+ try {
+ await axios.put(
+ `config/set?cameras.${polygon.camera}.zones.${polygon.name}${renameAlertQueries}${renameDetectionQueries}`,
+ {
+ requires_restart: 0,
+ },
+ );
+
+ // Wait for the config to be updated
+ mutatedConfig = await updateConfig();
+ } catch (error) {
+ toast.error(`Failed to save config changes.`, {
+ position: "top-center",
+ });
+ return;
+ }
+ }
+
+ const coordinates = flattenPoints(
+ interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
+ ).join(",");
+
+ let objectQueries = objects
+ .map(
+ (object) =>
+ `&cameras.${polygon?.camera}.zones.${zoneName}.objects=${object}`,
+ )
+ .join("");
+
+ const same_objects =
+ form_objects.length == objects.length &&
+ form_objects.every(function (element, index) {
+ return element === objects[index];
+ });
+
+ // deleting objects
+ if (!objectQueries && !same_objects && !renamingZone) {
+ objectQueries = `&cameras.${polygon?.camera}.zones.${zoneName}.objects`;
+ }
+
+ const { alertQueries, detectionQueries } = reviewQueries(
+ zoneName,
+ review_alerts,
+ review_detections,
+ polygon.camera,
+ mutatedConfig?.cameras[polygon.camera]?.review.alerts.required_zones ||
+ [],
+ mutatedConfig?.cameras[polygon.camera]?.review.detections
+ .required_zones || [],
+ );
+
+ let inertiaQuery = "";
+ if (inertia) {
+ inertiaQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.inertia=${inertia}`;
+ }
+
+ let loiteringTimeQuery = "";
+ if (loitering_time) {
+ loiteringTimeQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.loitering_time=${loitering_time}`;
+ }
+
+ axios
+ .put(
+ `config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${objectQueries}${alertQueries}${detectionQueries}`,
+ { requires_restart: 0 },
+ )
+ .then((res) => {
+ if (res.status === 200) {
+ toast.success(`Zone (${zoneName}) has been saved.`, {
+ position: "top-center",
+ });
+ updateConfig();
+ } else {
+ toast.error(`Failed to save config changes: ${res.statusText}`, {
+ position: "top-center",
+ });
+ }
+ })
+ .catch((error) => {
+ toast.error(
+ `Failed to save config changes: ${error.response.data.message}`,
+ { position: "top-center" },
+ );
+ })
+ .finally(() => {
+ setIsLoading(false);
+ });
+ },
+ [
+ config,
+ updateConfig,
+ polygon,
+ scaledWidth,
+ scaledHeight,
+ setIsLoading,
+ cameraConfig,
+ ],
+ );
+
+ function onSubmit(values: z.infer) {
+ if (activePolygonIndex === undefined || !values || !polygons) {
+ return;
+ }
+ setIsLoading(true);
+
+ saveToConfig(
+ values as ZoneFormValuesType,
+ polygons[activePolygonIndex].objects,
+ );
+
+ if (onSave) {
+ onSave();
+ }
+ }
+
+ if (!polygon) {
+ return;
+ }
+
+ return (
+ <>
+
+
+ {polygon.name.length ? "Edit" : "New"} Zone
+
+
+
+ Zones allow you to define a specific area of the frame so you can
+ determine whether or not an object is within a particular area.
+
+
+
+ {polygons && activePolygonIndex !== undefined && (
+
+
+ {polygons[activePolygonIndex].points.length}{" "}
+ {polygons[activePolygonIndex].points.length > 1 ||
+ polygons[activePolygonIndex].points.length == 0
+ ? "points"
+ : "point"}
+ {polygons[activePolygonIndex].isFinished && (
+
+ )}
+
+
+
+ )}
+
+ Click to draw a polygon on the image.
+
+
+
+
+
+
+ >
+ );
+}
+
+type ZoneObjectSelectorProps = {
+ camera: string;
+ zoneName: string;
+ selectedLabels: string[];
+ updateLabelFilter: (labels: string[] | undefined) => void;
+};
+
+export function ZoneObjectSelector({
+ camera,
+ zoneName,
+ selectedLabels,
+ updateLabelFilter,
+}: ZoneObjectSelectorProps) {
+ const { data: config } = useSWR("config");
+
+ const cameraConfig = useMemo(() => {
+ if (config && camera) {
+ return config.cameras[camera];
+ }
+ }, [config, camera]);
+
+ const allLabels = useMemo(() => {
+ if (!cameraConfig || !config) {
+ return [];
+ }
+
+ const labels = new Set();
+
+ // Object.values(config.cameras).forEach((camera) => {
+ // camera.objects.track.forEach((label) => {
+ // if (!ATTRIBUTE_LABELS.includes(label)) {
+ // labels.add(label);
+ // }
+ // });
+ // });
+
+ cameraConfig.objects.track.forEach((label) => {
+ if (!ATTRIBUTE_LABELS.includes(label)) {
+ labels.add(label);
+ }
+ });
+
+ if (zoneName) {
+ if (cameraConfig.zones[zoneName]) {
+ cameraConfig.zones[zoneName].objects.forEach((label) => {
+ if (!ATTRIBUTE_LABELS.includes(label)) {
+ labels.add(label);
+ }
+ });
+ }
+ }
+
+ return [...labels].sort() || [];
+ }, [config, cameraConfig, zoneName]);
+
+ const [currentLabels, setCurrentLabels] = useState(
+ selectedLabels,
+ );
+
+ useEffect(() => {
+ updateLabelFilter(currentLabels);
+ }, [currentLabels, updateLabelFilter]);
+
+ return (
+ <>
+
+
+
+ All Objects
+
+ {
+ if (isChecked) {
+ setCurrentLabels([]);
+ }
+ }}
+ />
+
+
+
+ {allLabels.map((item) => (
+
+
+ {item.replaceAll("_", " ")}
+
+ {
+ if (isChecked) {
+ const updatedLabels = currentLabels
+ ? [...currentLabels]
+ : [];
+
+ updatedLabels.push(item);
+ setCurrentLabels(updatedLabels);
+ } else {
+ const updatedLabels = currentLabels
+ ? [...currentLabels]
+ : [];
+
+ // can not deselect the last item
+ if (updatedLabels.length > 1) {
+ updatedLabels.splice(updatedLabels.indexOf(item), 1);
+ setCurrentLabels(updatedLabels);
+ }
+ }
+ }}
+ />
+
+ ))}
+
+
+ >
+ );
+}
diff --git a/web/src/components/ui/icon-wrapper.tsx b/web/src/components/ui/icon-wrapper.tsx
new file mode 100644
index 000000000..f87d18c62
--- /dev/null
+++ b/web/src/components/ui/icon-wrapper.tsx
@@ -0,0 +1,21 @@
+import { ForwardedRef, forwardRef } from "react";
+import { IconType } from "react-icons";
+
+interface IconWrapperProps {
+ icon: IconType;
+ className?: string;
+ [key: string]: any;
+}
+
+const IconWrapper = forwardRef(
+ (
+ { icon: Icon, className, ...props }: IconWrapperProps,
+ ref: ForwardedRef,
+ ) => (
+
+
+
+ ),
+);
+
+export default IconWrapper;
diff --git a/web/src/components/ui/separator.tsx b/web/src/components/ui/separator.tsx
new file mode 100644
index 000000000..6d7f12265
--- /dev/null
+++ b/web/src/components/ui/separator.tsx
@@ -0,0 +1,29 @@
+import * as React from "react"
+import * as SeparatorPrimitive from "@radix-ui/react-separator"
+
+import { cn } from "@/lib/utils"
+
+const Separator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(
+ (
+ { className, orientation = "horizontal", decorative = true, ...props },
+ ref
+ ) => (
+
+ )
+)
+Separator.displayName = SeparatorPrimitive.Root.displayName
+
+export { Separator }
diff --git a/web/src/components/ui/slider.tsx b/web/src/components/ui/slider.tsx
index 0f57209d8..8a3e93747 100644
--- a/web/src/components/ui/slider.tsx
+++ b/web/src/components/ui/slider.tsx
@@ -18,7 +18,7 @@ const Slider = React.forwardRef<
-
+
));
Slider.displayName = SliderPrimitive.Root.displayName;
diff --git a/web/src/components/ui/switch.tsx b/web/src/components/ui/switch.tsx
index 162260f87..1cb6c7ffa 100644
--- a/web/src/components/ui/switch.tsx
+++ b/web/src/components/ui/switch.tsx
@@ -18,6 +18,7 @@ const Switch = React.forwardRef<
diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx
index b80040203..c6a509f90 100644
--- a/web/src/pages/Settings.tsx
+++ b/web/src/pages/Settings.tsx
@@ -1,44 +1,272 @@
-import Heading from "@/components/ui/heading";
-import { Label } from "@/components/ui/label";
import {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectLabel,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { Switch } from "@/components/ui/switch";
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
+import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
+import MotionTuner from "@/components/settings/MotionTuner";
+import MasksAndZones from "@/components/settings/MasksAndZones";
+import { Button } from "@/components/ui/button";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import useOptimisticState from "@/hooks/use-optimistic-state";
+import { isMobile } from "react-device-detect";
+import { FaVideo } from "react-icons/fa";
+import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
+import useSWR from "swr";
+import General from "@/components/settings/General";
+import FilterSwitch from "@/components/filter/FilterSwitch";
+import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
+import { PolygonType } from "@/types/canvas";
+import ObjectSettings from "@/components/settings/ObjectSettings";
+
+export default function Settings() {
+ const settingsViews = [
+ "general",
+ "objects",
+ "masks / zones",
+ "motion tuner",
+ ] as const;
+
+ type SettingsType = (typeof settingsViews)[number];
+ const [page, setPage] = useState("general");
+ const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
+
+ const { data: config } = useSWR("config");
+
+ // TODO: confirm leave page
+ const [unsavedChanges, setUnsavedChanges] = useState(false);
+ const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
+
+ const cameras = useMemo(() => {
+ if (!config) {
+ return [];
+ }
+
+ return Object.values(config.cameras)
+ .filter((conf) => conf.ui.dashboard && conf.enabled)
+ .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
+ }, [config]);
+
+ const [selectedCamera, setSelectedCamera] = useState("");
+
+ const [filterZoneMask, setFilterZoneMask] = useState();
+
+ const handleDialog = useCallback(
+ (save: boolean) => {
+ if (unsavedChanges && save) {
+ // TODO
+ }
+ setConfirmationDialogOpen(false);
+ setUnsavedChanges(false);
+ },
+ [unsavedChanges],
+ );
+
+ useEffect(() => {
+ if (cameras.length) {
+ setSelectedCamera(cameras[0].name);
+ }
+ // only run once
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
-function Settings() {
return (
- <>
- Settings
-
-
{}} />
-
- Always show PTZ controls for ONVIF cameras
-
+
+
+
+
{
+ if (value) {
+ setPageToggle(value);
+ }
+ }}
+ >
+ {Object.values(settingsViews).map((item) => (
+
+ {item}
+
+ ))}
+
+
+ {(page == "objects" ||
+ page == "masks / zones" ||
+ page == "motion tuner") && (
+
+ {page == "masks / zones" && (
+
+ )}
+
+
+ )}
-
-
-
-
-
-
-
-
- Live Mode
- JSMpeg
- MSE
- WebRTC
-
-
-
+
+ {page == "general" && }
+ {page == "objects" && (
+
+ )}
+ {page == "masks / zones" && (
+
+ )}
+ {page == "motion tuner" && (
+
+ )}
- >
+ {confirmationDialogOpen && (
+
setConfirmationDialogOpen(false)}
+ >
+
+
+ You have unsaved changes.
+
+ Do you want to save your changes before continuing?
+
+
+
+ handleDialog(false)}>
+ Cancel
+
+ handleDialog(true)}>
+ Save
+
+
+
+
+ )}
+
);
}
-export default Settings;
+type CameraSelectButtonProps = {
+ allCameras: CameraConfig[];
+ selectedCamera: string;
+ setSelectedCamera: React.Dispatch
>;
+};
+
+function CameraSelectButton({
+ allCameras,
+ selectedCamera,
+ setSelectedCamera,
+}: CameraSelectButtonProps) {
+ const [open, setOpen] = useState(false);
+
+ if (!allCameras.length) {
+ return;
+ }
+
+ const trigger = (
+
+
+
+ {selectedCamera == undefined
+ ? "No Camera"
+ : selectedCamera.replaceAll("_", " ")}
+
+
+ );
+ const content = (
+ <>
+ {isMobile && (
+ <>
+
+ Camera
+
+
+ >
+ )}
+
+
+ {allCameras.map((item) => (
+ {
+ if (isChecked) {
+ setSelectedCamera(item.name);
+ setOpen(false);
+ }
+ }}
+ />
+ ))}
+
+
+ >
+ );
+
+ if (isMobile) {
+ return (
+ {
+ if (!open) {
+ setSelectedCamera(selectedCamera);
+ }
+
+ setOpen(open);
+ }}
+ >
+ {trigger}
+
+ {content}
+
+
+ );
+ }
+
+ return (
+ {
+ if (!open) {
+ setSelectedCamera(selectedCamera);
+ }
+
+ setOpen(open);
+ }}
+ >
+ {trigger}
+ {content}
+
+ );
+}
diff --git a/web/src/pages/SubmitPlus.tsx b/web/src/pages/SubmitPlus.tsx
index 88c449362..098f0d5ff 100644
--- a/web/src/pages/SubmitPlus.tsx
+++ b/web/src/pages/SubmitPlus.tsx
@@ -24,7 +24,7 @@ import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { DualThumbSlider } from "@/components/ui/slider";
import { Event } from "@/types/event";
-import { FrigateConfig } from "@/types/frigateConfig";
+import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig";
import axios from "axios";
import { useCallback, useEffect, useMemo, useState } from "react";
import { isMobile } from "react-device-detect";
@@ -199,8 +199,6 @@ export default function SubmitPlus() {
);
}
-const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"];
-
type PlusFilterGroupProps = {
selectedCameras: string[] | undefined;
selectedLabels: string[] | undefined;
@@ -237,7 +235,7 @@ function PlusFilterGroup({
cameras.forEach((camera) => {
const cameraConfig = config.cameras[camera];
cameraConfig.objects.track.forEach((label) => {
- if (!ATTRIBUTES.includes(label)) {
+ if (!ATTRIBUTE_LABELS.includes(label)) {
labels.add(label);
}
});
diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts
new file mode 100644
index 000000000..5eb04e98f
--- /dev/null
+++ b/web/src/types/canvas.ts
@@ -0,0 +1,31 @@
+export type PolygonType = "zone" | "motion_mask" | "object_mask";
+
+export type Polygon = {
+ typeIndex: number;
+ camera: string;
+ name: string;
+ type: PolygonType;
+ objects: string[];
+ points: number[][];
+ isFinished: boolean;
+ // isUnsaved: boolean;
+ color: number[];
+};
+
+export type ZoneFormValuesType = {
+ name: string;
+ inertia: number;
+ loitering_time: number;
+ isFinished: boolean;
+ objects: string[];
+ review_alerts: boolean;
+ review_detections: boolean;
+};
+
+export type ObjectMaskFormValuesType = {
+ objects: string;
+ polygon: {
+ isFinished: boolean;
+ name: string;
+ };
+};
diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts
index a6c6b3864..b5c89f5db 100644
--- a/web/src/types/frigateConfig.ts
+++ b/web/src/types/frigateConfig.ts
@@ -21,6 +21,14 @@ export interface BirdseyeConfig {
width: number;
}
+export const ATTRIBUTE_LABELS = [
+ "amazon",
+ "face",
+ "fedex",
+ "license_plate",
+ "ups",
+];
+
export interface CameraConfig {
audio: {
enabled: boolean;
@@ -106,7 +114,7 @@ export interface CameraConfig {
objects: {
filters: {
[objectName: string]: {
- mask: string | null;
+ mask: string[] | null;
max_area: number;
max_ratio: number;
min_area: number;
@@ -163,6 +171,14 @@ export interface CameraConfig {
};
sync_recordings: boolean;
};
+ review: {
+ alerts: {
+ required_zones: string[];
+ };
+ detections: {
+ required_zones: string[];
+ };
+ };
rtmp: {
enabled: boolean;
};
@@ -199,7 +215,9 @@ export interface CameraConfig {
coordinates: string;
filters: Record;
inertia: number;
+ loitering_time: number;
objects: string[];
+ color: number[];
};
};
}
@@ -327,7 +345,7 @@ export interface FrigateConfig {
objects: {
filters: {
[objectName: string]: {
- mask: string | null;
+ mask: string[] | null;
max_area: number;
max_ratio: number;
min_area: number;
@@ -336,7 +354,7 @@ export interface FrigateConfig {
threshold: number;
};
};
- mask: string;
+ mask: string[];
track: string[];
};
diff --git a/web/src/utils/canvasUtil.ts b/web/src/utils/canvasUtil.ts
new file mode 100644
index 000000000..12bd6b167
--- /dev/null
+++ b/web/src/utils/canvasUtil.ts
@@ -0,0 +1,102 @@
+import { Vector2d } from "konva/lib/types";
+
+export const getAveragePoint = (points: number[]): Vector2d => {
+ let totalX = 0;
+ let totalY = 0;
+ for (let i = 0; i < points.length; i += 2) {
+ totalX += points[i];
+ totalY += points[i + 1];
+ }
+ return {
+ x: totalX / (points.length / 2),
+ y: totalY / (points.length / 2),
+ };
+};
+
+export const getDistance = (node1: number[], node2: number[]): string => {
+ const diffX = Math.abs(node1[0] - node2[0]);
+ const diffY = Math.abs(node1[1] - node2[1]);
+ const distanceInPixel = Math.sqrt(diffX * diffX + diffY * diffY);
+ return distanceInPixel.toFixed(2);
+};
+
+export const dragBoundFunc = (
+ stageWidth: number,
+ stageHeight: number,
+ vertexRadius: number,
+ pos: Vector2d,
+): Vector2d => {
+ let x = pos.x;
+ let y = pos.y;
+ if (pos.x + vertexRadius > stageWidth) x = stageWidth;
+ if (pos.x - vertexRadius < 0) x = 0;
+ if (pos.y + vertexRadius > stageHeight) y = stageHeight;
+ if (pos.y - vertexRadius < 0) y = 0;
+ return { x, y };
+};
+
+export const minMax = (points: number[]): [number, number] => {
+ return points.reduce(
+ (acc: [number | undefined, number | undefined], val) => {
+ acc[0] = acc[0] === undefined || val < acc[0] ? val : acc[0];
+ acc[1] = acc[1] === undefined || val > acc[1] ? val : acc[1];
+ return acc;
+ },
+ [undefined, undefined],
+ ) as [number, number];
+};
+
+export const interpolatePoints = (
+ points: number[][],
+ width: number,
+ height: number,
+ newWidth: number,
+ newHeight: number,
+): number[][] => {
+ const newPoints: number[][] = [];
+
+ for (const [x, y] of points) {
+ const newX = Math.min(+((x * newWidth) / width).toFixed(3), newWidth);
+ const newY = Math.min(+((y * newHeight) / height).toFixed(3), newHeight);
+ newPoints.push([newX, newY]);
+ }
+
+ return newPoints;
+};
+
+export const parseCoordinates = (coordinatesString: string) => {
+ const coordinates = coordinatesString.split(",");
+ const points = [];
+
+ for (let i = 0; i < coordinates.length; i += 2) {
+ const x = parseFloat(coordinates[i]);
+ const y = parseFloat(coordinates[i + 1]);
+ points.push([x, y]);
+ }
+
+ return points;
+};
+
+export const flattenPoints = (points: number[][]): number[] => {
+ return points.reduce((acc, point) => [...acc, ...point], []);
+};
+
+export const toRGBColorString = (color: number[], darkened: boolean) => {
+ if (color.length !== 3) {
+ return "rgb(220,0,0,0.5)";
+ }
+
+ return `rgba(${color[2]},${color[1]},${color[0]},${darkened ? "0.7" : "0.3"})`;
+};
+
+export const masksAreIdentical = (arr1: string[], arr2: string[]): boolean => {
+ if (arr1.length !== arr2.length) {
+ return false;
+ }
+ for (let i = 0; i < arr1.length; i++) {
+ if (arr1[i] !== arr2[i]) {
+ return false;
+ }
+ }
+ return true;
+};
diff --git a/web/src/utils/zoneEdutUtil.ts b/web/src/utils/zoneEdutUtil.ts
new file mode 100644
index 000000000..ce5ed3d96
--- /dev/null
+++ b/web/src/utils/zoneEdutUtil.ts
@@ -0,0 +1,50 @@
+export const reviewQueries = (
+ name: string,
+ review_alerts: boolean,
+ review_detections: boolean,
+ camera: string,
+ alertsZones: string[],
+ detectionsZones: string[],
+) => {
+ let alertQueries = "";
+ let detectionQueries = "";
+ let same_alerts = false;
+ let same_detections = false;
+
+ const alerts = new Set(alertsZones || []);
+
+ if (review_alerts) {
+ alerts.add(name);
+ } else {
+ same_alerts = !alerts.has(name);
+ alerts.delete(name);
+ }
+
+ alertQueries = [...alerts]
+ .map((zone) => `&cameras.${camera}.review.alerts.required_zones=${zone}`)
+ .join("");
+
+ const detections = new Set(detectionsZones || []);
+
+ if (review_detections) {
+ detections.add(name);
+ } else {
+ same_detections = !detections.has(name);
+ detections.delete(name);
+ }
+
+ detectionQueries = [...detections]
+ .map(
+ (zone) => `&cameras.${camera}.review.detections.required_zones=${zone}`,
+ )
+ .join("");
+
+ if (!alertQueries && !same_alerts) {
+ alertQueries = `&cameras.${camera}.review.alerts`;
+ }
+ if (!detectionQueries && !same_detections) {
+ detectionQueries = `&cameras.${camera}.review.detections`;
+ }
+
+ return { alertQueries, detectionQueries };
+};
diff --git a/web/tailwind.config.js b/web/tailwind.config.js
index 80cbc4a12..93faa87ca 100644
--- a/web/tailwind.config.js
+++ b/web/tailwind.config.js
@@ -53,6 +53,7 @@ module.exports = {
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
+ variant: "hsl(var(--primary-variant))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
diff --git a/web/themes/theme-default.css b/web/themes/theme-default.css
index 07b924956..3d2619c72 100644
--- a/web/themes/theme-default.css
+++ b/web/themes/theme-default.css
@@ -24,6 +24,9 @@
--primary: hsl(222.2, 37.4%, 11.2%);
--primary: 222.2 47.4% 11.2%;
+ --primary-variant: hsl(222.2, 37.4%, 24.2%);
+ --primary-variant: 222.2 47.4% 24.2%;
+
--primary-foreground: hsl(210, 40%, 98%);
--primary-foreground: 210 40% 98%;
@@ -115,12 +118,15 @@
--popover: hsl(0, 0%, 15%);
--popover: 0, 0%, 15%;
- --popover-foreground: hsl(0, 0%, 100%);
- --popover-foreground: 210 40% 98%;
+ --popover-foreground: hsl(0, 0%, 98%);
+ --popover-foreground: 0 0% 98%;
--primary: hsl(0, 0%, 91%);
--primary: 0 0% 91%;
+ --primary-variant: hsl(0, 0%, 64%);
+ --primary-variant: 0 0% 64%;
+
--primary-foreground: hsl(0, 0%, 9%);
--primary-foreground: 0 0% 9%;
@@ -133,8 +139,8 @@
--secondary-highlight: hsl(0, 0%, 25%);
--secondary-highlight: 0 0% 25%;
- --muted: hsl(0, 0%, 8%);
- --muted: 0 0% 8%;
+ --muted: hsl(0, 0%, 12%);
+ --muted: 0 0% 12%;
--muted-foreground: hsl(0, 0%, 32%);
--muted-foreground: 0 0% 32%;