mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-27 00:44:41 +03:00
Compare commits
3
Commits
aff9799451
...
fd98977506
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd98977506 | ||
|
|
6816050a46 | ||
|
|
c70a0802b8 |
+1
-1
@@ -24,7 +24,7 @@ yell
|
|||||||
sigh
|
sigh
|
||||||
singing
|
singing
|
||||||
choir
|
choir
|
||||||
sodeling
|
yodeling
|
||||||
chant
|
chant
|
||||||
mantra
|
mantra
|
||||||
child_singing
|
child_singing
|
||||||
|
|||||||
@@ -121,6 +121,31 @@ cameras:
|
|||||||
</TabItem>
|
</TabItem>
|
||||||
</ConfigTabs>
|
</ConfigTabs>
|
||||||
|
|
||||||
|
## 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
|
## 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)
|
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)
|
||||||
|
|||||||
Vendored
+1
@@ -5093,6 +5093,7 @@ paths:
|
|||||||
NOTES:
|
NOTES:
|
||||||
- Creating a manual event does not trigger an update to /events MQTT topic.
|
- 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.
|
- 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
|
operationId: create_event_events__camera_name___label__create_post
|
||||||
parameters:
|
parameters:
|
||||||
- name: camera_name
|
- name: camera_name
|
||||||
|
|||||||
@@ -1748,6 +1748,7 @@ async def delete_events(request: Request, body: EventsDeleteBody):
|
|||||||
NOTES:
|
NOTES:
|
||||||
- Creating a manual event does not trigger an update to /events MQTT topic.
|
- 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.
|
- 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(
|
def create_event(
|
||||||
|
|||||||
@@ -1172,6 +1172,28 @@ class LicensePlateProcessingMixin:
|
|||||||
|
|
||||||
return rep["plate"], rep["conf"], rep["char_confidences"], rep["area"]
|
return rep["plate"], rep["conf"], rep["char_confidences"], rep["area"]
|
||||||
|
|
||||||
|
def _passes_plate_filters(self, camera: str, plate: str) -> bool:
|
||||||
|
"""Check a plate against the configured length and format filters."""
|
||||||
|
if len(plate) < self.lpr_config.min_plate_length:
|
||||||
|
logger.debug(
|
||||||
|
f"{camera}: Filtered out plate '{plate}' due to length ({len(plate)} < {self.lpr_config.min_plate_length})"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.lpr_config.format:
|
||||||
|
try:
|
||||||
|
if not re.fullmatch(self.lpr_config.format, plate):
|
||||||
|
logger.debug(
|
||||||
|
f"{camera}: Filtered out plate '{plate}' due to format mismatch"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except re.error:
|
||||||
|
logger.error(
|
||||||
|
f"{camera}: Invalid regex in LPR format configuration: {self.lpr_config.format}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def _generate_plate_event(self, camera: str, plate: str, plate_score: float) -> str:
|
def _generate_plate_event(self, camera: str, plate: str, plate_score: float) -> str:
|
||||||
"""Generate a unique ID for a plate event based on camera and text."""
|
"""Generate a unique ID for a plate event based on camera and text."""
|
||||||
now = datetime.datetime.now().timestamp()
|
now = datetime.datetime.now().timestamp()
|
||||||
@@ -1511,10 +1533,14 @@ class LicensePlateProcessingMixin:
|
|||||||
plate_id = None
|
plate_id = None
|
||||||
|
|
||||||
for existing_id, data in self.detected_license_plates.items():
|
for existing_id, data in self.detected_license_plates.items():
|
||||||
|
# entries from the object pipeline on this camera have no
|
||||||
|
# last_seen until they pass the filters below
|
||||||
|
last_seen = data.get("last_seen")
|
||||||
|
|
||||||
if (
|
if (
|
||||||
data["camera"] == camera
|
data["camera"] == camera
|
||||||
and data["last_seen"] is not None
|
and last_seen is not None
|
||||||
and current_time - data["last_seen"]
|
and current_time - last_seen
|
||||||
<= self.config.cameras[camera].lpr.expire_time
|
<= self.config.cameras[camera].lpr.expire_time
|
||||||
):
|
):
|
||||||
similarity = JaroWinkler.similarity(data["plate"], top_plate)
|
similarity = JaroWinkler.similarity(data["plate"], top_plate)
|
||||||
@@ -1525,6 +1551,11 @@ class LicensePlateProcessingMixin:
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
if plate_id is None:
|
if plate_id is None:
|
||||||
|
# the event id doubles as the cluster key, so a plate rejected
|
||||||
|
# after this point would leave an entry that never expires
|
||||||
|
if not self._passes_plate_filters(camera, top_plate):
|
||||||
|
return
|
||||||
|
|
||||||
plate_id = self._generate_plate_event(camera, top_plate, avg_confidence)
|
plate_id = self._generate_plate_event(camera, top_plate, avg_confidence)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"{camera}: New plate event for dedicated LPR camera {plate_id}: {top_plate}"
|
f"{camera}: New plate event for dedicated LPR camera {plate_id}: {top_plate}"
|
||||||
@@ -1569,27 +1600,12 @@ class LicensePlateProcessingMixin:
|
|||||||
f"{camera}: Clustering changed top plate '{top_plate}' (conf: {avg_confidence:.3f}) to rep '{rep_plate}' (conf: {rep_conf:.3f})"
|
f"{camera}: Clustering changed top plate '{top_plate}' (conf: {avg_confidence:.3f}) to rep '{rep_plate}' (conf: {rep_conf:.3f})"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply length and format filters to the clustered representative
|
# filter the clustered representative rather than individual OCR
|
||||||
# rather than individual OCR readings, so noisy variants still
|
# readings, so noisy variants still contribute to clustering even
|
||||||
# contribute to clustering even when they don't pass on their own.
|
# when they don't pass on their own
|
||||||
if len(rep_plate) < self.lpr_config.min_plate_length:
|
if not self._passes_plate_filters(camera, rep_plate):
|
||||||
logger.debug(
|
|
||||||
f"{camera}: Filtered out clustered plate '{rep_plate}' due to length ({len(rep_plate)} < {self.lpr_config.min_plate_length})"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.lpr_config.format:
|
|
||||||
try:
|
|
||||||
if not re.fullmatch(self.lpr_config.format, rep_plate):
|
|
||||||
logger.debug(
|
|
||||||
f"{camera}: Filtered out clustered plate '{rep_plate}' due to format mismatch"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
except re.error:
|
|
||||||
logger.error(
|
|
||||||
f"{camera}: Invalid regex in LPR format configuration: {self.lpr_config.format}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update stored rep
|
# Update stored rep
|
||||||
self.detected_license_plates[id].update(
|
self.detected_license_plates[id].update(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -392,6 +392,32 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
return self._publish_segment_end(segment, prev_data)
|
return self._publish_segment_end(segment, prev_data)
|
||||||
return None
|
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(
|
def update_existing_segment(
|
||||||
self,
|
self,
|
||||||
segment: PendingReviewSegment,
|
segment: PendingReviewSegment,
|
||||||
@@ -734,24 +760,19 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
manual_info["label"]
|
manual_info["label"]
|
||||||
)
|
)
|
||||||
if topic == DetectionTypeEnum.api:
|
if topic == DetectionTypeEnum.api:
|
||||||
# manual_info["label"] contains 'label: sub_label'
|
severity = self.get_manual_event_severity(
|
||||||
# so split out the label without modifying manual_info
|
camera, manual_info["label"]
|
||||||
det_labels = self.config.cameras[
|
)
|
||||||
camera
|
|
||||||
].review.detections.labels
|
if severity == SeverityEnum.alert:
|
||||||
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:
|
|
||||||
current_segment.severity = SeverityEnum.alert
|
current_segment.severity = SeverityEnum.alert
|
||||||
current_segment.last_alert_time = manual_info[
|
current_segment.last_alert_time = manual_info[
|
||||||
"end_time"
|
"end_time"
|
||||||
]
|
]
|
||||||
|
elif severity == SeverityEnum.detection:
|
||||||
|
current_segment.last_detection_time = manual_info[
|
||||||
|
"end_time"
|
||||||
|
]
|
||||||
elif (
|
elif (
|
||||||
topic == DetectionTypeEnum.lpr
|
topic == DetectionTypeEnum.lpr
|
||||||
and self.config.cameras[camera].review.detections.enabled
|
and self.config.cameras[camera].review.detections.enabled
|
||||||
@@ -765,21 +786,12 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
current_segment.detections[manual_info["event_id"]] = (
|
current_segment.detections[manual_info["event_id"]] = (
|
||||||
manual_info["label"]
|
manual_info["label"]
|
||||||
)
|
)
|
||||||
if (
|
if topic == DetectionTypeEnum.api:
|
||||||
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 (
|
if (
|
||||||
not self.config.cameras[
|
self.get_manual_event_severity(
|
||||||
camera
|
camera, manual_info["label"]
|
||||||
].review.detections.enabled
|
)
|
||||||
or det_labels is None
|
== SeverityEnum.alert
|
||||||
or manual_info["label"].split(": ")[0] not in det_labels
|
|
||||||
):
|
):
|
||||||
current_segment.severity = SeverityEnum.alert
|
current_segment.severity = SeverityEnum.alert
|
||||||
elif (
|
elif (
|
||||||
@@ -853,18 +865,9 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
detections,
|
detections,
|
||||||
)
|
)
|
||||||
elif topic == DetectionTypeEnum.api:
|
elif topic == DetectionTypeEnum.api:
|
||||||
severity = None
|
severity = self.get_manual_event_severity(
|
||||||
# manual_info["label"] contains 'label: sub_label'
|
camera, manual_info["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
|
|
||||||
|
|
||||||
if severity:
|
if severity:
|
||||||
api_segment = PendingReviewSegment(
|
api_segment = PendingReviewSegment(
|
||||||
|
|||||||
@@ -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")
|
||||||
|
)
|
||||||
@@ -426,7 +426,6 @@
|
|||||||
"radio": "Radio",
|
"radio": "Radio",
|
||||||
"field_recording": "Field Recording",
|
"field_recording": "Field Recording",
|
||||||
"scream": "Scream",
|
"scream": "Scream",
|
||||||
"sodeling": "Sodeling",
|
|
||||||
"chird": "Chird",
|
"chird": "Chird",
|
||||||
"change_ringing": "Change Ringing",
|
"change_ringing": "Change Ringing",
|
||||||
"shofar": "Shofar",
|
"shofar": "Shofar",
|
||||||
|
|||||||
Reference in New Issue
Block a user