From 998a734fe3847b2bb8618b8715593e8a8feef11e Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 11 Jul 2024 18:29:20 -0500 Subject: [PATCH] Camera settings view for alerts/detections --- web/package-lock.json | 30 ++ web/package.json | 1 + web/src/components/ui/checkbox.tsx | 28 ++ web/src/pages/Settings.tsx | 9 + web/src/types/frigateConfig.ts | 2 + web/src/views/settings/CameraSettingsView.tsx | 475 ++++++++++++++++++ 6 files changed, 545 insertions(+) create mode 100644 web/src/components/ui/checkbox.tsx create mode 100644 web/src/views/settings/CameraSettingsView.tsx diff --git a/web/package-lock.json b/web/package-lock.json index 3bf882213..75100ba90 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,6 +12,7 @@ "@hookform/resolvers": "^3.9.0", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-aspect-ratio": "^1.1.0", + "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-context-menu": "^2.2.1", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", @@ -1182,6 +1183,35 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.1.tgz", + "integrity": "sha512-0i/EKJ222Afa1FE0C6pNJxDq1itzcl3HChE9DwskA4th4KRse8ojx8a1nVcOjwJdbpDLcz7uol77yYnQNMHdKw==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", diff --git a/web/package.json b/web/package.json index b2c442085..704507c82 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "@hookform/resolvers": "^3.9.0", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-aspect-ratio": "^1.1.0", + "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-context-menu": "^2.2.1", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", diff --git a/web/src/components/ui/checkbox.tsx b/web/src/components/ui/checkbox.tsx new file mode 100644 index 000000000..ddbdd01d8 --- /dev/null +++ b/web/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 7d501d23e..c355a97c6 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -30,6 +30,7 @@ import { PolygonType } from "@/types/canvas"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import scrollIntoView from "scroll-into-view-if-needed"; import GeneralSettingsView from "@/views/settings/GeneralSettingsView"; +import CameraSettingsView from "@/views/settings/CameraSettingsView"; import ObjectSettingsView from "@/views/settings/ObjectSettingsView"; import MotionTunerView from "@/views/settings/MotionTunerView"; import MasksAndZonesView from "@/views/settings/MasksAndZonesView"; @@ -38,6 +39,7 @@ import AuthenticationView from "@/views/settings/AuthenticationView"; export default function Settings() { const settingsViews = [ "general", + "camera settings", "masks / zones", "motion tuner", "debug", @@ -136,6 +138,7 @@ export default function Settings() { {(page == "debug" || + page == "camera settings" || page == "masks / zones" || page == "motion tuner") && (
@@ -158,6 +161,12 @@ export default function Settings() { {page == "debug" && ( )} + {page == "camera settings" && ( + + )} {page == "masks / zones" && ( >; +}; + +export default function CameraSettingsView({ + selectedCamera, + setUnsavedChanges, +}: CameraSettingsViewProps) { + const { data: config, mutate: updateConfig } = + useSWR("config"); + + const cameraConfig = useMemo(() => { + if (config && selectedCamera) { + return config.cameras[selectedCamera]; + } + }, [config, selectedCamera]); + + const [changedValue, setChangedValue] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [selectDetections, setSelectDetections] = useState(false); + + const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; + + const zones = useMemo(() => { + if (cameraConfig) { + return Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({ + camera: cameraConfig.name, + name, + objects: zoneData.objects, + color: zoneData.color, + })); + } + }, [cameraConfig]); + + const formSchema = z.object({ + alerts_zones: z.array(z.string()), + detections_zones: z.array(z.string()), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: "onChange", + defaultValues: { + alerts_zones: cameraConfig?.review.alerts.required_zones || [], + detections_zones: cameraConfig?.review.detections.required_zones || [], + }, + }); + + const watchedAlertsZones = form.watch("alerts_zones"); + const watchedDetectionsZones = form.watch("detections_zones"); + + useEffect(() => { + form.reset({ + alerts_zones: cameraConfig?.review.alerts.required_zones ?? [], + detections_zones: cameraConfig?.review.detections.required_zones || [], + }); + setSelectDetections( + !!cameraConfig?.review.detections.required_zones?.length, + ); + }, [cameraConfig, form]); + + const handleCheckedChange = useCallback( + (isChecked: boolean) => { + if (!isChecked) { + form.reset({ + alerts_zones: watchedAlertsZones, + detections_zones: + cameraConfig?.review.detections.required_zones || [], + }); + } + setSelectDetections(isChecked as boolean); + }, + [watchedAlertsZones, cameraConfig, form], + ); + + const alertsLabels = useMemo(() => { + return cameraConfig?.review.alerts.labels + ? cameraConfig.review.alerts.labels.join(", ") + : ""; + }, [cameraConfig]); + + const detectionsLabels = useMemo(() => { + return cameraConfig?.review.detections.labels + ? cameraConfig.review.detections.labels.join(", ") + : ""; + }, [cameraConfig]); + + const saveToConfig = useCallback( + async ( + { + name: zoneName, + review_alerts, + review_detections, + }: CameraSettingsValuesType, // values submitted via the form + ) => { + if (!zoneName) { + return; + } + let mutatedConfig = config; + + const { alertQueries, detectionQueries } = reviewQueries( + zoneName, + review_alerts, + review_detections, + selectedCamera, + mutatedConfig?.cameras[selectedCamera]?.review.alerts.required_zones || + [], + mutatedConfig?.cameras[selectedCamera]?.review.detections + .required_zones || [], + ); + + axios + .put( + `config/set?cameras.${selectedCamera}.zones.${zoneName}?????${alertQueries}${detectionQueries}`, + { requires_restart: 0 }, + ) + .then((res) => { + if (res.status === 200) { + toast.success( + `Zone (${zoneName}) has been saved. Restart Frigate to apply changes.`, + { + 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, setIsLoading, selectedCamera], + ); + + const onCancel = useCallback(() => { + setChangedValue(false); + removeMessage( + "camera_settings", + `alert_detection_settings_${selectedCamera}`, + ); + }, [removeMessage, selectedCamera]); + + useEffect(() => { + if (changedValue) { + addMessage( + "motion_tuner", + `Unsaved changes to alert/detection settings for (${selectedCamera})`, + undefined, + `alert_detection_settings_${selectedCamera}`, + ); + } else { + removeMessage( + "camera_settings", + `alert_detection_settings_${selectedCamera}`, + ); + } + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [changedValue, selectedCamera]); + + function onSubmit(values: z.infer) { + setIsLoading(true); + + saveToConfig(values as CameraSettingsValuesType); + } + + useEffect(() => { + document.title = "Camera Settings - Frigate"; + }, []); + + if (!cameraConfig && !selectedCamera) { + return ; + } + + return ( + <> +
+ +
+ + Camera Settings + + + + + + Review Classification + + +
+

+ Not every segment of video captured by Frigate may be of the same + level of interest to you. Frigate categorizes review items as + alerts and detections. By default, all person and{" "} + car objects are considered alerts. You can refine + categorization of your review items by configuring required zones + for them. +

+
+ + Read the Documentation{" "} + + +
+
+ +
+ +
+ ( + + {zones && zones?.length > 0 ? ( + <> +
+ + Alerts{" "} + + + + Select zones for Alerts + +
+
+ {zones?.map((zone) => ( + { + return ( + + + { + return checked + ? field.onChange([ + ...field.value, + zone.name, + ]) + : field.onChange( + field.value?.filter( + (value) => + value !== zone.name, + ), + ); + }} + /> + + + {zone.name.replaceAll("_", " ")} + + + ); + }} + /> + ))} +
+ + ) : ( +
+ No zones are defined for this camera. +
+ )} + +
+ All {alertsLabels} objects + {watchedAlertsZones && watchedAlertsZones.length > 0 + ? ` detected in ${watchedAlertsZones.map((zone) => capitalizeFirstLetter(zone)).join(", ")}` + : ""}{" "} + on{" "} + {capitalizeFirstLetter( + cameraConfig?.name ?? "", + ).replaceAll("_", " ")}{" "} + will be shown as Alerts. +
+
+ )} + /> + + ( + + {zones && zones?.length > 0 ? ( + <> +
+
+ + Detections{" "} + + +
+
+ +
+ +
+
+
+ + {selectDetections && ( +
+ + Select zones for Detections + +
+ )} + + {selectDetections && ( +
+ {zones?.map((zone) => ( + { + return ( + + + { + return checked + ? field.onChange([ + ...field.value, + zone.name, + ]) + : field.onChange( + field.value?.filter( + (value) => + value !== zone.name, + ), + ); + }} + /> + + + {zone.name.replaceAll("_", " ")} + + + ); + }} + /> + ))} +
+ )} + + + ) : ( + "" + )} + +
+ All {detectionsLabels} objects not classified as Alerts{" "} + {watchedDetectionsZones && + watchedDetectionsZones.length > 0 + ? ` that are detected in ${watchedDetectionsZones.map((zone) => capitalizeFirstLetter(zone)).join(", ")}` + : ""}{" "} + on{" "} + {capitalizeFirstLetter( + cameraConfig?.name ?? "", + ).replaceAll("_", " ")}{" "} + will be shown as Detections + {!selectDetections && ", regardless of zone"}. +
+
+ )} + /> +
+ +
+ + +
+
+ + + +
+
+ + ); +}