From fd989775068d04280cffbaaba44c5dca9120418d Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:16:02 -0500 Subject: [PATCH] Categorize manual events as alerts when their label is an alert label (#23981) * Categorize manual events as alerts when their label is an alert label * tweak docs --- docs/docs/configuration/review.md | 25 +++ docs/static/frigate-api.yaml | 1 + frigate/api/event.py | 1 + frigate/review/maintainer.py | 83 +++++----- .../test/test_review_manual_event_severity.py | 154 ++++++++++++++++++ 5 files changed, 224 insertions(+), 40 deletions(-) create mode 100644 frigate/test/test_review_manual_event_severity.py diff --git a/docs/docs/configuration/review.md b/docs/docs/configuration/review.md index e9ba4df254..14360769c3 100644 --- a/docs/docs/configuration/review.md +++ b/docs/docs/configuration/review.md @@ -121,6 +121,31 @@ cameras: +## Categorizing manual events + +Events created with the [create manual event API](../integrations/api/create-event-events-camera-name-label-create-post.api.mdx) are categorized with the same label lists, using the label from the request path: + +1. If alerts are enabled and the label is listed in `review -> alerts -> labels`, the review item is an alert. +2. Otherwise, if detections are enabled and the label is listed in `review -> detections -> labels`, the review item is a detection. +3. If the label is in neither list, the review item is an alert, or no review item is created if alerts are disabled. + +This means manual events are alerts unless you explicitly list their label as a detection label. For example, to have PIR sensors create detections instead of alerts, post to `/api/events/front_door/pir_sensor/create` with the following config: + +```yaml {5-7} +cameras: + front_door: + review: + detections: + labels: + - pir_sensor +``` + +:::note + +Required zones do not apply to manual events, since they are created through the API rather than by the object tracker. Setting `review -> alerts -> labels` to an empty list also does not stop manual events from becoming alerts, as a label in neither list still falls back to an alert. + +::: + ## Restricting review items to specific zones By default a review item will be created if any `review -> alerts -> labels` and `review -> detections -> labels` are detected anywhere in the camera frame. You will likely want to configure review items to only be created when the object enters an area of interest, [see the zone docs for more information](./zones.md#restricting-alerts-and-detections-to-specific-zones) diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index fe467e0c62..80c1955bdb 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -5093,6 +5093,7 @@ paths: NOTES: - Creating a manual event does not trigger an update to /events MQTT topic. - If a duration is set to null, the event will need to be ended manually by calling /events/{event_id}/end. + - The review item is an alert unless the label is listed in the camera's review -> detections -> labels config. operationId: create_event_events__camera_name___label__create_post parameters: - name: camera_name diff --git a/frigate/api/event.py b/frigate/api/event.py index 4e99d67035..835e7918cc 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -1748,6 +1748,7 @@ async def delete_events(request: Request, body: EventsDeleteBody): NOTES: - Creating a manual event does not trigger an update to /events MQTT topic. - If a duration is set to null, the event will need to be ended manually by calling /events/{event_id}/end. + - The review item is an alert unless the label is listed in the camera's review -> detections -> labels config. """, ) def create_event( diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index 65c68e2e01..ea892a18f9 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -392,6 +392,32 @@ class ReviewSegmentMaintainer(threading.Thread): return self._publish_segment_end(segment, prev_data) return None + def get_manual_event_severity(self, camera: str, label: str) -> SeverityEnum | None: + """Determine the review severity for a manual event label. + + Alert labels take precedence over detection labels, matching how + tracked objects are categorized. Labels in neither list default to + alerts so manual events keep their historical severity. + """ + review_config = self.config.cameras[camera].review + # label contains 'label: sub_label', only the label is categorized + label = label.split(": ")[0] + + if review_config.alerts.enabled and label in review_config.alerts.labels: + return SeverityEnum.alert + + if ( + review_config.detections.enabled + and review_config.detections.labels is not None + and label in review_config.detections.labels + ): + return SeverityEnum.detection + + if review_config.alerts.enabled: + return SeverityEnum.alert + + return None + def update_existing_segment( self, segment: PendingReviewSegment, @@ -734,24 +760,19 @@ class ReviewSegmentMaintainer(threading.Thread): manual_info["label"] ) if topic == DetectionTypeEnum.api: - # manual_info["label"] contains 'label: sub_label' - # so split out the label without modifying manual_info - det_labels = self.config.cameras[ - camera - ].review.detections.labels - if ( - self.config.cameras[camera].review.detections.enabled - and det_labels is not None - and manual_info["label"].split(": ")[0] in det_labels - ): - current_segment.last_detection_time = manual_info[ - "end_time" - ] - elif self.config.cameras[camera].review.alerts.enabled: + severity = self.get_manual_event_severity( + camera, manual_info["label"] + ) + + if severity == SeverityEnum.alert: current_segment.severity = SeverityEnum.alert current_segment.last_alert_time = manual_info[ "end_time" ] + elif severity == SeverityEnum.detection: + current_segment.last_detection_time = manual_info[ + "end_time" + ] elif ( topic == DetectionTypeEnum.lpr and self.config.cameras[camera].review.detections.enabled @@ -765,21 +786,12 @@ class ReviewSegmentMaintainer(threading.Thread): current_segment.detections[manual_info["event_id"]] = ( manual_info["label"] ) - if ( - topic == DetectionTypeEnum.api - and self.config.cameras[camera].review.alerts.enabled - ): - # manual_info["label"] contains 'label: sub_label' - # so split out the label without modifying manual_info - det_labels = self.config.cameras[ - camera - ].review.detections.labels + if topic == DetectionTypeEnum.api: if ( - not self.config.cameras[ - camera - ].review.detections.enabled - or det_labels is None - or manual_info["label"].split(": ")[0] not in det_labels + self.get_manual_event_severity( + camera, manual_info["label"] + ) + == SeverityEnum.alert ): current_segment.severity = SeverityEnum.alert elif ( @@ -853,18 +865,9 @@ class ReviewSegmentMaintainer(threading.Thread): detections, ) elif topic == DetectionTypeEnum.api: - severity = None - # manual_info["label"] contains 'label: sub_label' - # so split out the label without modifying manual_info - det_labels = self.config.cameras[camera].review.detections.labels - if ( - self.config.cameras[camera].review.detections.enabled - and det_labels is not None - and manual_info["label"].split(": ")[0] in det_labels - ): - severity = SeverityEnum.detection - elif self.config.cameras[camera].review.alerts.enabled: - severity = SeverityEnum.alert + severity = self.get_manual_event_severity( + camera, manual_info["label"] + ) if severity: api_segment = PendingReviewSegment( diff --git a/frigate/test/test_review_manual_event_severity.py b/frigate/test/test_review_manual_event_severity.py new file mode 100644 index 0000000000..9dd61e18c5 --- /dev/null +++ b/frigate/test/test_review_manual_event_severity.py @@ -0,0 +1,154 @@ +"""Tests for manual event severity categorization. + +Regression coverage for manual events created via the events API being +categorized as detections when their label appears in both the alerts and +detections label lists. Alert labels must win, matching how tracked objects +are categorized, and labels in neither list must default to alerts so the +historical behavior of the API is preserved. +""" + +import unittest + +from frigate.config import FrigateConfig +from frigate.review.maintainer import ReviewSegmentMaintainer +from frigate.review.types import SeverityEnum + +BASE_CONFIG = """ +mqtt: + enabled: False +cameras: + front_door: + ffmpeg: + inputs: + - path: rtsp://10.0.0.1:554/video + roles: + - detect + detect: + width: 1920 + height: 1080 + fps: 5 +%s +""" + + +class TestManualEventSeverity(unittest.TestCase): + def _make_maintainer(self, review_config: str = "") -> ReviewSegmentMaintainer: + """Build a maintainer without invoking __init__ (avoids needing ZMQ + sockets, shared memory, and clip dirs). Only the config is read when + categorizing a manual event label.""" + maintainer = ReviewSegmentMaintainer.__new__(ReviewSegmentMaintainer) + maintainer.config = FrigateConfig.parse_yaml(BASE_CONFIG % review_config) + return maintainer + + def test_defaults_to_alert(self) -> None: + maintainer = self._make_maintainer() + + self.assertEqual( + maintainer.get_manual_event_severity("front_door", "person"), + SeverityEnum.alert, + ) + + def test_unlisted_label_defaults_to_alert(self) -> None: + maintainer = self._make_maintainer( + """ + review: + detections: + labels: + - dog +""" + ) + + self.assertEqual( + maintainer.get_manual_event_severity("front_door", "pir_sensor"), + SeverityEnum.alert, + ) + + def test_detection_label_is_detection(self) -> None: + maintainer = self._make_maintainer( + """ + review: + alerts: + labels: + - person + detections: + labels: + - pir_sensor +""" + ) + + self.assertEqual( + maintainer.get_manual_event_severity("front_door", "pir_sensor"), + SeverityEnum.detection, + ) + + def test_alert_label_wins_over_detection_label(self) -> None: + maintainer = self._make_maintainer( + """ + review: + alerts: + labels: + - person + detections: + labels: + - person + - dog +""" + ) + + self.assertEqual( + maintainer.get_manual_event_severity("front_door", "person"), + SeverityEnum.alert, + ) + + def test_sub_label_is_stripped_before_categorizing(self) -> None: + maintainer = self._make_maintainer( + """ + review: + alerts: + labels: + - person + detections: + labels: + - person +""" + ) + + self.assertEqual( + maintainer.get_manual_event_severity("front_door", "person: Bob"), + SeverityEnum.alert, + ) + + def test_alert_label_is_detection_when_alerts_disabled(self) -> None: + maintainer = self._make_maintainer( + """ + review: + alerts: + enabled: False + labels: + - person + detections: + labels: + - person +""" + ) + + self.assertEqual( + maintainer.get_manual_event_severity("front_door", "person"), + SeverityEnum.detection, + ) + + def test_no_severity_when_alerts_disabled_and_label_not_a_detection(self) -> None: + maintainer = self._make_maintainer( + """ + review: + alerts: + enabled: False + detections: + labels: + - dog +""" + ) + + self.assertIsNone( + maintainer.get_manual_event_severity("front_door", "pir_sensor") + )