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/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 = ( + + ); + 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 ( + <> +
+
+ + { + if (isChecked) { + updateZoneMaskFilter(undefined); + } + }} + /> +
+ +
+ {["zone", "motion_mask", "object_mask"].map((item) => ( +
+ + { + 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 +
+ {}} /> + +
+ +
+ +
+ + ); +} 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{" "} + + +
+
+
+
+ + + + + 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{" "} + + +
+
+
+
+ + + + + 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{" "} + + +
+
+
+
+ + + + + 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{" "} + + +
+
+ +
+
+
+ +
+

+ 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} +
+
+
+
+
+ +
+

+ 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 for darker scenes. Default: ON +
+
+ { + handleMotionConfigChange({ improve_contrast: isChecked }); + }} + /> +
+
+
+
+ + +
+
+
+ + {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. +
+ + + +
+ +
+ ( + + + + )} + /> + ( + + Objects + + + The object type that that applies to this object mask. + + + + )} + /> + ( + + + + )} + /> +
+
+
+ + +
+
+
+ + + ); +} + +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. +
+ + + +
+ + ( + + Name + + + + + Name must be at least 2 characters and must not be the name of + a camera or another zone. + + + + )} + /> + + ( + + Inertia + + + + + Specifies how many frames that an object must be in a zone + before they are considered in the zone. Default: 3 + + + + )} + /> + + ( + + Loitering Time + + + + + Sets a minimum amount of time in seconds that the object must + be in the zone for it to activate. Default: 0 + + + + )} + /> + + + Objects + + List of objects that apply to this zone. + + { + if (activePolygonIndex === undefined || !polygons) { + return; + } + const updatedPolygons = [...polygons]; + const activePolygon = updatedPolygons[activePolygonIndex]; + updatedPolygons[activePolygonIndex] = { + ...activePolygon, + objects: objects ?? [], + }; + setPolygons(updatedPolygons); + }} + /> + + + + + ( + +
+ Alerts + + When an object enters this zone, ensure it is marked as an + alert. + +
+ + + +
+ )} + /> + ( + +
+ Detections + + When an object enters this zone, ensure it is marked as a + detection. + +
+ + + +
+ )} + /> + ( + + + + )} + /> +
+ + +
+ + + + ); +} + +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 ( + <> +
+
+ + { + if (isChecked) { + setCurrentLabels([]); + } + }} + /> +
+ +
+ {allLabels.map((item) => ( +
+ + { + 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 -
- {}} /> - +
+
+
+ { + if (value) { + setPageToggle(value); + } + }} + > + {Object.values(settingsViews).map((item) => ( + +
{item}
+
+ ))} +
+
+ {(page == "objects" || + page == "masks / zones" || + page == "motion tuner") && ( +
+ {page == "masks / zones" && ( + + )} + +
+ )}
- -
- +
+ {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 = ( + + ); + 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%;