Fix detection not correctly being started after an alert ends (#24534)

* Fix detection not correctly being started after an alert ends

* Improve coverage

* Fix incorrect behavior

* Handle unsent thumbs

* Add more tests
This commit is contained in:
Nicolas Mowen
2026-10-01 09:51:10 -05:00
committed by GitHub
parent 24a236a152
commit 0936f4b4ef
2 changed files with 992 additions and 55 deletions
+135 -55
View File
@@ -66,6 +66,9 @@ class PendingReviewSegment:
self.zones = zones
self.audio = audio
self.classification_state_changes: list[dict[str, Any]] = []
# detection-level activity after the last alert activity, split by the
# detection cutoff, these are published when the alert is cut off
self.pending_detections: list[PendingReviewSegment] = []
self.thumb_time: float | None = None
self.last_alert_time: float | None = None
self.last_detection_time: float = frame_time
@@ -83,6 +86,20 @@ class PendingReviewSegment:
CLIPS_DIR, f"review/thumb-{self.camera}-{self.id}.webp"
)
def add_object(self, obj: dict[str, Any], attributes: list[str]) -> None:
"""Add a tracked object's label, sub label, and zones to the segment."""
if not obj["sub_label"]:
self.detections[obj["id"]] = obj["label"]
elif obj["sub_label"][0] in attributes:
self.detections[obj["id"]] = obj["sub_label"][0]
else:
self.detections[obj["id"]] = f"{obj['label']}-verified"
self.sub_labels[obj["id"]] = obj["sub_label"][0]
for zone in obj["current_zones"]:
if zone not in self.zones:
self.zones.append(zone)
def update_frame(
self,
camera_config: CameraConfig,
@@ -435,9 +452,29 @@ class ReviewSegmentMaintainer(threading.Thread):
segment.last_detection_time = now
prev_data = segment.get_data(False)
return self._publish_segment_end(segment, prev_data)
end_time = self._publish_segment_end(segment, prev_data)
self._publish_pending_detections(segment, None)
return end_time
return None
def _publish_pending_detections(
self, segment: PendingReviewSegment, ongoing_since: float | None
) -> None:
"""Publish the detections held while an ended alert was active.
A detection with activity after ongoing_since stays open, only the
latest can. With None every detection is ended, this does not read the
camera config since a removed camera is no longer in it.
"""
for pending in segment.pending_detections:
self._activate_segment(pending)
self._publish_segment_start(pending)
if ongoing_since is None or pending.last_detection_time < ongoing_since:
self._publish_segment_end(pending, pending.get_data(False))
segment.pending_detections = []
def get_manual_event_severity(self, camera: str, label: str) -> SeverityEnum | None:
"""Determine the review severity for a manual event label.
@@ -470,6 +507,55 @@ class ReviewSegmentMaintainer(threading.Thread):
self.indefinite_events.pop(camera, None)
self.recent_classification_state_changes.pop(camera, None)
def _track_pending_detection(
self,
segment: PendingReviewSegment,
camera_config: CameraConfig,
frame_name: str,
frame_time: float,
objects: list[dict[str, Any]],
) -> None:
"""Hold detection-level activity seen after an alert's last alert
activity, starting a separate detection when the gap since the previous
activity exceeds the detection cutoff."""
pending = segment.pending_detections[-1] if segment.pending_detections else None
if pending is None or frame_time > (
pending.last_detection_time + camera_config.review.detections.cutoff_time
):
pending = PendingReviewSegment(
segment.camera,
frame_time,
SeverityEnum.detection,
{},
sub_labels={},
audio=set(),
zones=[],
)
segment.pending_detections.append(pending)
pending.last_detection_time = frame_time
for obj in objects:
pending.add_object(obj, self.config.all_attributes)
if len(objects) <= pending.frame_active_count:
return
try:
yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv
)
except FileNotFoundError:
return
if yuv_frame is None:
logger.debug(f"Failed to get frame {frame_name} from SHM")
return
pending.update_frame(camera_config, yuv_frame, objects)
self.frame_manager.close(frame_name)
def update_existing_segment(
self,
segment: PendingReviewSegment,
@@ -505,6 +591,21 @@ class ReviewSegmentMaintainer(threading.Thread):
should_update_state = True
should_update_image = True
# alert activity resumed, so the pending detection activity
# falls within this alert
for pending in segment.pending_detections:
segment.detections.update(pending.detections)
segment.sub_labels.update(pending.sub_labels)
for zone in pending.zones:
if zone not in segment.zones:
segment.zones.append(zone)
Path(pending.frame_path).unlink(missing_ok=True)
should_update_state = True
segment.pending_detections = []
if activity.has_activity_category(SeverityEnum.detection):
if (
segment.last_detection_time is None
@@ -512,6 +613,8 @@ class ReviewSegmentMaintainer(threading.Thread):
):
segment.last_detection_time = frame_time
pending_objects: list[dict[str, Any]] = []
for object in activity.get_all_objects():
# Alert-level objects should always be added (they extend/upgrade the segment)
# Detection-level objects should only be added if:
@@ -522,23 +625,22 @@ class ReviewSegmentMaintainer(threading.Thread):
if not is_alert_object and segment.severity == SeverityEnum.alert:
# This is a detection-level object
if (
segment.last_alert_time is not None
and frame_time > segment.last_alert_time
):
pending_objects.append(object)
# Only add if it started during the alert's active period
if object["start_time"] > segment.last_alert_time:
continue
if not object["sub_label"]:
segment.detections[object["id"]] = object["label"]
elif object["sub_label"][0] in self.config.all_attributes:
segment.detections[object["id"]] = object["sub_label"][0]
else:
segment.detections[object["id"]] = f"{object['label']}-verified"
segment.sub_labels[object["id"]] = object["sub_label"][0]
segment.add_object(object, self.config.all_attributes)
# keep zones up to date
if len(object["current_zones"]) > 0:
for zone in object["current_zones"]:
if zone not in segment.zones:
segment.zones.append(zone)
if pending_objects:
self._track_pending_detection(
segment, camera_config, frame_name, frame_time, pending_objects
)
if len(activity.get_all_objects()) > segment.frame_active_count:
should_update_state = True
@@ -590,50 +692,28 @@ class ReviewSegmentMaintainer(threading.Thread):
except FileNotFoundError:
return
if (
segment.severity == SeverityEnum.alert
and segment.last_alert_time is not None
and frame_time
> (segment.last_alert_time + camera_config.review.alerts.cutoff_time)
):
needs_new_detection = (
segment.last_detection_time > segment.last_alert_time
and (
segment.last_detection_time
+ camera_config.review.detections.cutoff_time
)
> frame_time
)
last_detection_time = segment.last_detection_time
end_time = self._publish_segment_end(segment, prev_data)
if needs_new_detection:
new_detections: dict[str, str] = {}
new_zones = set()
for o in activity.categorized_objects["detections"]:
new_detections[o["id"]] = o["label"]
new_zones.update(o["current_zones"])
if new_detections:
new_segment = PendingReviewSegment(
segment.camera,
end_time,
SeverityEnum.detection,
new_detections,
sub_labels={},
audio=set(),
zones=list(new_zones),
)
self._activate_segment(new_segment)
self._publish_segment_start(new_segment)
new_segment.last_detection_time = last_detection_time
elif segment.severity == SeverityEnum.detection and frame_time > (
# detection-level activity must not keep an alert open, it continues
# in a new detection segment once the alert is cut off
if (
segment.severity == SeverityEnum.alert
and segment.last_alert_time is not None
and frame_time
> (segment.last_alert_time + camera_config.review.alerts.cutoff_time)
):
self._publish_segment_end(segment, prev_data)
self._publish_pending_detections(
segment, frame_time - camera_config.review.detections.cutoff_time
)
elif (
not has_activity
and segment.severity == SeverityEnum.detection
and frame_time
> (
segment.last_detection_time
+ camera_config.review.detections.cutoff_time
):
self._publish_segment_end(segment, prev_data)
)
):
self._publish_segment_end(segment, prev_data)
def check_if_new_segment(
self,
+857
View File
@@ -0,0 +1,857 @@
"""Tests for how tracked objects flow into review segments.
Frames are fed through the maintainer's run loop with mocked subscribers so
the dispatch between starting and updating segments is exercised, and the
published review updates are checked for the expected alert, detection, or
lack of a review item.
"""
import json
import tempfile
import threading
import unittest
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import numpy as np
from frigate.comms.detections_updater import DetectionTypeEnum
from frigate.config import FrigateConfig
from frigate.review.maintainer import ReviewSegmentMaintainer
CAMERA = "front_door"
BASE_CONFIG = """
mqtt:
enabled: False
record:
enabled: True
cameras:
front_door:
ffmpeg:
inputs:
- path: rtsp://10.0.0.1:554/video
roles:
- detect
detect:
width: 640
height: 360
fps: 5
zones:
driveway:
coordinates: 0,0,320,0,320,360,0,360
yard:
coordinates: 320,0,640,0,640,360,320,360
%s
"""
class ReviewFlowTestCase(unittest.TestCase):
review_config = ""
def setUp(self) -> None:
self.clips_dir = clips_dir = tempfile.TemporaryDirectory()
self.addCleanup(clips_dir.cleanup)
clips_patch = patch("frigate.review.maintainer.CLIPS_DIR", clips_dir.name)
clips_patch.start()
self.addCleanup(clips_patch.stop)
self.maintainer = self._make_maintainer(self.review_config)
def _make_maintainer(self, review_config: str) -> ReviewSegmentMaintainer:
"""Build a maintainer without invoking __init__ (avoids needing ZMQ
sockets and shared memory)."""
maintainer = ReviewSegmentMaintainer.__new__(ReviewSegmentMaintainer)
threading.Thread.__init__(maintainer)
maintainer.config = FrigateConfig.parse_yaml(BASE_CONFIG % review_config)
maintainer.active_review_segments = {}
maintainer.indefinite_events = {}
maintainer.recent_classification_state_changes = {}
maintainer.requestor = MagicMock()
maintainer.review_publisher = MagicMock()
maintainer.config_subscriber = MagicMock()
maintainer.config_subscriber.check_for_updates.return_value = {}
maintainer.detection_subscriber = MagicMock()
maintainer.frame_manager = MagicMock()
maintainer.frame_manager.get.side_effect = lambda _name, shape: np.zeros(
shape, np.uint8
)
return maintainer
@property
def stationary_threshold(self) -> int:
return self.maintainer.config.cameras[CAMERA].detect.stationary.threshold
@property
def alert_cutoff(self) -> int:
return self.maintainer.config.cameras[CAMERA].review.alerts.cutoff_time
@property
def detection_cutoff(self) -> int:
return self.maintainer.config.cameras[CAMERA].review.detections.cutoff_time
def tracked(
self,
obj_id: str,
label: str,
frame_time: float,
*,
start_time: float = 0,
zones: list[str] | None = None,
stationary: bool = False,
loitering: bool = False,
moved: bool = True,
false_positive: bool = False,
sub_label: tuple[str, float] | None = None,
) -> dict[str, Any]:
"""Build a tracked object as published by the object processor."""
return {
"id": obj_id,
"label": label,
"sub_label": sub_label,
"frame_time": frame_time,
"start_time": start_time,
"motionless_count": self.stationary_threshold if stationary else 0,
"pending_loitering": loitering,
"position_changes": 1 if moved else 0,
"false_positive": false_positive,
"current_zones": zones or [],
"box": (100, 100, 200, 200),
}
def feed(self, *frames: tuple[float, list[dict[str, Any]]]) -> None:
"""Run the maintainer loop over the given (frame_time, objects) frames."""
queue = [
(
DetectionTypeEnum.video.value,
(CAMERA, f"{CAMERA}_{frame_time}", frame_time, objects, [], []),
)
for frame_time, objects in frames
]
self.maintainer.stop_event = threading.Event()
def next_update(timeout: float) -> Any:
if not queue:
self.maintainer.stop_event.set()
return None
return queue.pop(0)
self.maintainer.detection_subscriber.check_for_update.side_effect = next_update
self.maintainer.run()
def reviews(self) -> list[dict[str, Any]]:
"""All review updates published on the reviews topic."""
return [
json.loads(c.args[1])
for c in self.maintainer.requestor.send_data.call_args_list
if c.args[0] == "reviews"
]
def review_summary(self) -> list[tuple[str, str]]:
return [(r["type"], r["after"]["severity"]) for r in self.reviews()]
def non_update_summary(self) -> list[tuple[str, str]]:
return [(t, s) for t, s in self.review_summary() if t != "update"]
def thumbnails(self) -> set[str]:
"""Names of the review thumbnails on disk."""
return {t.name for t in Path(self.clips_dir.name, "review").iterdir()}
def assert_no_review(self) -> None:
self.assertEqual(self.reviews(), [])
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
class TestReviewSeverity(ReviewFlowTestCase):
def test_alert_label_creates_alert(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1)]))
self.assertEqual(self.review_summary(), [("new", "alert")])
self.assertEqual(self.reviews()[0]["after"]["data"]["objects"], ["person"])
def test_non_alert_label_creates_detection(self) -> None:
self.feed((1, [self.tracked("d1", "dog", 1)]))
self.assertEqual(self.review_summary(), [("new", "detection")])
self.assertEqual(self.reviews()[0]["after"]["data"]["objects"], ["dog"])
def test_alert_and_detection_objects_create_single_alert(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1), self.tracked("d1", "dog", 1)]))
self.assertEqual(self.review_summary(), [("new", "alert")])
self.assertCountEqual(
self.reviews()[0]["after"]["data"]["objects"], ["person", "dog"]
)
def test_no_objects_creates_nothing(self) -> None:
self.feed((1, []), (2, []))
self.assert_no_review()
class TestIgnoredObjects(ReviewFlowTestCase):
def test_stationary_object_creates_nothing(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1, stationary=True)]))
self.assert_no_review()
def test_stationary_loitering_object_creates_alert(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, stationary=True, loitering=True)])
)
self.assertEqual(self.review_summary(), [("new", "alert")])
def test_object_that_never_moved_creates_nothing(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1, moved=False)]))
self.assert_no_review()
def test_object_not_detected_in_current_frame_creates_nothing(self) -> None:
self.feed((2, [self.tracked("p1", "person", 1)]))
self.assert_no_review()
def test_false_positive_creates_nothing(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1, false_positive=True)]))
self.assert_no_review()
class TestAlertRequiredZones(ReviewFlowTestCase):
review_config = """
review:
alerts:
required_zones: driveway
"""
def test_alert_label_in_required_zone_creates_alert(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1, zones=["driveway"])]))
self.assertEqual(self.review_summary(), [("new", "alert")])
self.assertEqual(self.reviews()[0]["after"]["data"]["zones"], ["driveway"])
def test_alert_label_outside_required_zone_creates_detection(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1, zones=["yard"])]))
self.assertEqual(self.review_summary(), [("new", "detection")])
def test_alert_label_in_no_zone_creates_detection(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1)]))
self.assertEqual(self.review_summary(), [("new", "detection")])
def test_detection_upgrades_to_alert_when_object_enters_required_zone(
self,
) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, zones=["yard"])]),
(2, [self.tracked("p1", "person", 2, zones=["driveway"])]),
)
self.assertEqual(
self.review_summary(), [("new", "detection"), ("update", "alert")]
)
self.assertEqual(
self.reviews()[0]["after"]["id"], self.reviews()[1]["after"]["id"]
)
class TestDetectionRequiredZones(ReviewFlowTestCase):
review_config = """
review:
detections:
required_zones: yard
"""
def test_detection_label_in_required_zone_creates_detection(self) -> None:
self.feed((1, [self.tracked("d1", "dog", 1, zones=["yard"])]))
self.assertEqual(self.review_summary(), [("new", "detection")])
def test_detection_label_outside_required_zone_creates_nothing(self) -> None:
self.feed((1, [self.tracked("d1", "dog", 1, zones=["driveway"])]))
self.assert_no_review()
def test_alert_label_ignores_detection_required_zones(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1, zones=["driveway"])]))
self.assertEqual(self.review_summary(), [("new", "alert")])
def test_activity_outside_required_zone_after_alert_is_not_held(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(5, [self.tracked("d1", "dog", 5, start_time=5, zones=["driveway"])]),
)
self.assertEqual(
self.maintainer.active_review_segments[CAMERA].pending_detections, []
)
self.feed((2 + self.alert_cutoff, []))
self.assertEqual(
self.non_update_summary(), [("new", "alert"), ("end", "alert")]
)
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
self.assertEqual(len(self.thumbnails()), 1)
def test_activity_inside_required_zone_after_alert_is_split_out(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(5, [self.tracked("d1", "dog", 5, start_time=5, zones=["yard"])]),
(2 + self.alert_cutoff, []),
)
# the dog is past the detection cutoff, so it is ended right away
self.assertEqual(
self.non_update_summary(),
[
("new", "alert"),
("end", "alert"),
("new", "detection"),
("end", "detection"),
],
)
detection = self.reviews()[-1]["after"]
self.assertEqual(detection["data"]["objects"], ["dog"])
self.assertEqual(detection["data"]["zones"], ["yard"])
class TestDetectionLabels(ReviewFlowTestCase):
review_config = """
review:
detections:
labels:
- dog
"""
def test_listed_label_creates_detection(self) -> None:
self.feed((1, [self.tracked("d1", "dog", 1)]))
self.assertEqual(self.review_summary(), [("new", "detection")])
def test_unlisted_label_creates_nothing(self) -> None:
self.feed((1, [self.tracked("c1", "cat", 1)]))
self.assert_no_review()
def test_unlisted_object_is_left_out_of_detection(self) -> None:
self.feed((1, [self.tracked("d1", "dog", 1), self.tracked("c1", "cat", 1)]))
self.assertEqual(self.review_summary(), [("new", "detection")])
self.assertEqual(self.reviews()[0]["after"]["data"]["objects"], ["dog"])
class TestAlertsDisabled(ReviewFlowTestCase):
review_config = """
review:
alerts:
enabled: False
"""
def test_alert_label_creates_detection(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1)]))
self.assertEqual(self.review_summary(), [("new", "detection")])
class TestDetectionsDisabled(ReviewFlowTestCase):
review_config = """
review:
detections:
enabled: False
"""
def test_alert_label_creates_alert(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1)]))
self.assertEqual(self.review_summary(), [("new", "alert")])
def test_non_alert_label_creates_nothing(self) -> None:
self.feed((1, [self.tracked("d1", "dog", 1)]))
self.assert_no_review()
def test_activity_after_alert_is_not_held(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(5, [self.tracked("d1", "dog", 5, start_time=5)]),
)
self.assertEqual(
self.maintainer.active_review_segments[CAMERA].pending_detections, []
)
self.feed((2 + self.alert_cutoff, []))
self.assertEqual(
self.non_update_summary(), [("new", "alert"), ("end", "alert")]
)
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
self.assertEqual(len(self.thumbnails()), 1)
class TestAlertsAndDetectionsDisabled(ReviewFlowTestCase):
review_config = """
review:
alerts:
enabled: False
detections:
enabled: False
"""
def test_nothing_is_created(self) -> None:
self.feed((1, [self.tracked("p1", "person", 1), self.tracked("d1", "dog", 1)]))
self.assert_no_review()
class TestReviewLifecycle(ReviewFlowTestCase):
def test_detection_upgrades_to_alert_when_alert_object_appears(self) -> None:
self.feed(
(1, [self.tracked("d1", "dog", 1)]),
(2, [self.tracked("d1", "dog", 2), self.tracked("p1", "person", 2)]),
)
self.assertEqual(
self.review_summary(), [("new", "detection"), ("update", "alert")]
)
new, update = self.reviews()
self.assertEqual(new["after"]["id"], update["after"]["id"])
self.assertCountEqual(update["after"]["data"]["objects"], ["dog", "person"])
def test_alert_does_not_downgrade_when_only_detection_objects_remain(
self,
) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1), self.tracked("d1", "dog", 1)]),
(2, [self.tracked("d1", "dog", 2)]),
(3, [self.tracked("d1", "dog", 3)]),
)
self.assertEqual({severity for _, severity in self.review_summary()}, {"alert"})
self.assertEqual(
self.maintainer.active_review_segments[CAMERA].severity.value, "alert"
)
def test_alert_stays_open_until_cutoff(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1)]),
(1 + self.alert_cutoff, []),
)
self.assertNotIn("end", [t for t, _ in self.review_summary()])
self.assertIsNotNone(self.maintainer.active_review_segments.get(CAMERA))
def test_alert_ends_after_cutoff_at_last_alert_activity(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1)]),
(5, [self.tracked("p1", "person", 5)]),
(6, []),
(5 + self.alert_cutoff + 1, []),
)
end = self.reviews()[-1]
self.assertEqual(end["type"], "end")
self.assertEqual(end["after"]["severity"], "alert")
self.assertEqual(end["after"]["end_time"], 5)
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
def test_ongoing_alert_activity_extends_alert(self) -> None:
last_activity = 1 + self.alert_cutoff * 2
self.feed(
(1, [self.tracked("p1", "person", 1)]),
(
1 + self.alert_cutoff,
[self.tracked("p1", "person", 1 + self.alert_cutoff)],
),
(last_activity, [self.tracked("p1", "person", last_activity)]),
(last_activity + 1, []),
)
self.assertNotIn("end", [t for t, _ in self.review_summary()])
self.assertEqual(
self.maintainer.active_review_segments[CAMERA].last_alert_time,
last_activity,
)
def test_detection_ends_after_cutoff_at_last_detection_activity(self) -> None:
self.feed(
(1, [self.tracked("d1", "dog", 1)]),
(5, [self.tracked("d1", "dog", 5)]),
(5 + self.detection_cutoff, []),
)
self.assertNotIn("end", [t for t, _ in self.review_summary()])
self.feed((5 + self.detection_cutoff + 1, []))
end = self.reviews()[-1]
self.assertEqual(end["type"], "end")
self.assertEqual(end["after"]["severity"], "detection")
self.assertEqual(end["after"]["end_time"], 5)
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
def test_stationary_object_does_not_extend_alert(self) -> None:
self.feed(
(1, [self.tracked("c1", "car", 1)]),
(2, [self.tracked("c1", "car", 2, stationary=True)]),
(
2 + self.alert_cutoff,
[self.tracked("c1", "car", 2 + self.alert_cutoff, stationary=True)],
),
)
end = self.reviews()[-1]
self.assertEqual(end["type"], "end")
self.assertEqual(end["after"]["end_time"], 1)
def test_new_activity_after_end_creates_new_review(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1)]),
(2 + self.alert_cutoff, []),
(3 + self.alert_cutoff, [self.tracked("d1", "dog", 3 + self.alert_cutoff)]),
)
self.assertEqual(
self.non_update_summary(),
[("new", "alert"), ("end", "alert"), ("new", "detection")],
)
ids = {r["after"]["id"] for r in self.reviews() if r["type"] != "update"}
self.assertEqual(len(ids), 2)
def test_alert_splits_into_detection_when_detection_activity_continues(
self,
) -> None:
# the person leaves after the first frame while the dog keeps moving
dog_frames = [
(t, [self.tracked("d1", "dog", t, start_time=1)])
for t in range(11, 2 + self.alert_cutoff + 10, 10)
]
self.feed(
(
1,
[
self.tracked("p1", "person", 1, start_time=1),
self.tracked("d1", "dog", 1, start_time=1),
],
),
*dog_frames,
)
self.assertEqual(
self.non_update_summary(),
[("new", "alert"), ("end", "alert"), ("new", "detection")],
)
alert_end = next(r for r in self.reviews() if r["type"] == "end")
self.assertEqual(alert_end["after"]["end_time"], 1)
detection = self.maintainer.active_review_segments[CAMERA]
self.assertEqual(detection.severity.value, "detection")
self.assertEqual(detection.start_time, 11)
self.assertEqual(list(detection.detections.values()), ["dog"])
# the detection ends once the dog stops moving
last_dog_time = dog_frames[-1][0]
self.feed((last_dog_time + self.detection_cutoff + 1, []))
end = self.reviews()[-1]
self.assertEqual(end["type"], "end")
self.assertEqual(end["after"]["severity"], "detection")
self.assertEqual(end["after"]["id"], detection.id)
self.assertEqual(end["after"]["end_time"], last_dog_time)
def test_detection_starting_after_alert_activity_is_split_out(self) -> None:
# the dog only shows up after the person has left
dog_frames = [
(t, [self.tracked("d1", "dog", t, start_time=11)])
for t in range(11, 2 + self.alert_cutoff + 10, 10)
]
self.feed((1, [self.tracked("p1", "person", 1, start_time=1)]), *dog_frames)
self.assertEqual(
self.non_update_summary(),
[("new", "alert"), ("end", "alert"), ("new", "detection")],
)
alert_end = next(r for r in self.reviews() if r["type"] == "end")
self.assertEqual(alert_end["after"]["end_time"], 1)
self.assertEqual(alert_end["after"]["data"]["objects"], ["person"])
detection = self.maintainer.active_review_segments[CAMERA]
self.assertEqual(detection.severity.value, "detection")
self.assertEqual(detection.start_time, 11)
self.assertEqual(list(detection.detections.values()), ["dog"])
def test_detection_leaving_before_alert_cutoff_gets_detection(self) -> None:
# the dog comes and goes after the person left, all before the alert
# cutoff, so nothing is active when the alert ends
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(11, [self.tracked("d1", "dog", 11, start_time=11)]),
(21, [self.tracked("d1", "dog", 21, start_time=11)]),
(31, []),
(2 + self.alert_cutoff, []),
(22 + self.detection_cutoff, []),
)
self.assertEqual(
self.non_update_summary(),
[
("new", "alert"),
("end", "alert"),
("new", "detection"),
("end", "detection"),
],
)
ends = [r["after"] for r in self.reviews() if r["type"] == "end"]
self.assertEqual(ends[0]["end_time"], 1)
self.assertEqual(ends[0]["data"]["objects"], ["person"])
self.assertEqual(ends[1]["data"]["objects"], ["dog"])
self.assertEqual(ends[1]["start_time"], 11)
self.assertEqual(ends[1]["end_time"], 21)
def test_detection_older_than_detection_cutoff_gets_detection(self) -> None:
# the dog is gone longer than the detection cutoff by the time the
# alert ends, its activity still needs a detection
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(3, [self.tracked("d1", "dog", 3, start_time=3)]),
(5, [self.tracked("d1", "dog", 5, start_time=3)]),
(6, []),
(2 + self.alert_cutoff, []),
(3 + self.alert_cutoff, []),
)
self.assertGreater(2 + self.alert_cutoff, 5 + self.detection_cutoff)
self.assertEqual(
self.non_update_summary(),
[
("new", "alert"),
("end", "alert"),
("new", "detection"),
("end", "detection"),
],
)
ends = [r["after"] for r in self.reviews() if r["type"] == "end"]
self.assertEqual(ends[0]["end_time"], 1)
self.assertEqual(ends[0]["data"]["objects"], ["person"])
self.assertEqual(ends[1]["data"]["objects"], ["dog"])
self.assertEqual(ends[1]["start_time"], 3)
self.assertEqual(ends[1]["end_time"], 5)
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
def test_separate_detection_activity_after_alert_is_not_combined(self) -> None:
# the dog and cat are seen further apart than the detection cutoff
# while the alert is waiting to be cut off
cat_time = 3 + self.detection_cutoff + 6
self.assertLess(cat_time, 1 + self.alert_cutoff)
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(3, [self.tracked("d1", "dog", 3, start_time=3)]),
(4, []),
(cat_time, [self.tracked("c1", "cat", cat_time, start_time=cat_time)]),
(2 + self.alert_cutoff, []),
(cat_time + self.detection_cutoff + 1, []),
)
self.assertEqual(
self.non_update_summary(),
[
("new", "alert"),
("end", "alert"),
("new", "detection"),
("end", "detection"),
("new", "detection"),
("end", "detection"),
],
)
ends = [r["after"] for r in self.reviews() if r["type"] == "end"]
self.assertEqual(ends[0]["data"]["objects"], ["person"])
self.assertEqual(ends[1]["data"]["objects"], ["dog"])
self.assertEqual((ends[1]["start_time"], ends[1]["end_time"]), (3, 3))
self.assertEqual(ends[2]["data"]["objects"], ["cat"])
self.assertEqual(
(ends[2]["start_time"], ends[2]["end_time"]), (cat_time, cat_time)
)
for detection in ends[1:]:
self.assertTrue(Path(detection["thumb_path"]).is_file())
self.assertIsNotNone(detection["data"]["thumb_time"])
def test_resumed_alert_publishes_pending_detection_objects(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(5, [self.tracked("d1", "dog", 5, start_time=5)]),
(10, [self.tracked("p1", "person", 10, start_time=1)]),
)
latest = self.reviews()[-1]
self.assertEqual(latest["type"], "update")
self.assertEqual(latest["after"]["severity"], "alert")
self.assertCountEqual(latest["after"]["data"]["objects"], ["person", "dog"])
def test_split_detection_has_thumbnail_of_its_activity(self) -> None:
dog_frames = [
(t, [self.tracked("d1", "dog", t, start_time=11)])
for t in range(11, 2 + self.alert_cutoff + 10, 10)
]
self.feed((1, [self.tracked("p1", "person", 1, start_time=1)]), *dog_frames)
new_detection = next(
r["after"]
for r in self.reviews()
if r["type"] == "new" and r["after"]["severity"] == "detection"
)
self.assertTrue(Path(new_detection["thumb_path"]).is_file())
# captured from the dog's first frame, not a later fallback frame
self.assertIsNotNone(new_detection["data"]["thumb_time"])
self.assertIn(
f"{CAMERA}_11",
[c.args[0] for c in self.maintainer.frame_manager.get.call_args_list],
)
def assert_force_end_publishes_pending_detection(self, topic: str) -> None:
# the dog is held as a pending detection when the alert is ended
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(5, [self.tracked("d1", "dog", 5, start_time=5)]),
)
if topic == "remove":
# the config updater has already dropped the camera
self.maintainer.config.cameras.pop(CAMERA)
self.maintainer.config_subscriber.check_for_updates.side_effect = [
{topic: [CAMERA]}
]
self.feed()
self.assertEqual(
self.non_update_summary(),
[
("new", "alert"),
("end", "alert"),
("new", "detection"),
("end", "detection"),
],
)
end = self.reviews()[-1]["after"]
self.assertEqual(end["data"]["objects"], ["dog"])
self.assertEqual((end["start_time"], end["end_time"]), (5, 5))
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
# every thumbnail on disk belongs to a published review
published = {Path(r["after"]["thumb_path"]).name for r in self.reviews()}
self.assertEqual(self.thumbnails(), published)
def test_disabled_camera_publishes_pending_detections(self) -> None:
self.assert_force_end_publishes_pending_detection("enabled")
def test_removed_camera_publishes_pending_detections(self) -> None:
self.assert_force_end_publishes_pending_detection("remove")
def test_pending_thumbnail_removed_when_alert_resumes(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(5, [self.tracked("d1", "dog", 5, start_time=5)]),
)
alert = self.maintainer.active_review_segments[CAMERA]
pending_thumb = Path(alert.pending_detections[0].frame_path)
self.assertTrue(pending_thumb.is_file())
self.feed((10, [self.tracked("p1", "person", 10, start_time=1)]))
self.assertEqual(alert.pending_detections, [])
self.assertFalse(pending_thumb.exists())
self.assertEqual(self.thumbnails(), {Path(alert.frame_path).name})
def test_multiple_pending_objects_share_one_detection(self) -> None:
# objects within the detection cutoff of each other, whether seen
# together or later, make up one detection
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(
5,
[
self.tracked("d1", "dog", 5, start_time=5),
self.tracked("c1", "cat", 5, start_time=5),
],
),
(20, [self.tracked("b1", "bird", 20, start_time=20)]),
(21, []),
(2 + self.alert_cutoff, []),
(21 + self.detection_cutoff, []),
)
self.assertEqual(
self.non_update_summary(),
[
("new", "alert"),
("end", "alert"),
("new", "detection"),
("end", "detection"),
],
)
end = self.reviews()[-1]["after"]
self.assertCountEqual(end["data"]["objects"], ["dog", "cat", "bird"])
self.assertCountEqual(end["data"]["detections"], ["d1", "c1", "b1"])
self.assertEqual((end["start_time"], end["end_time"]), (5, 20))
# the thumbnail is framed on both objects seen together, the bird
# alone is fewer objects so it does not replace it
frames = [c.args[0] for c in self.maintainer.frame_manager.get.call_args_list]
self.assertIn(f"{CAMERA}_5", frames)
self.assertNotIn(f"{CAMERA}_20", frames)
class TestSplitDetectionLabels(ReviewFlowTestCase):
review_config = """
review:
alerts:
labels:
- person
"""
def test_split_detection_keeps_sub_labels_and_zones(self) -> None:
self.feed(
(1, [self.tracked("p1", "person", 1, start_time=1)]),
(
5,
[
self.tracked(
"d1",
"dog",
5,
start_time=5,
zones=["yard"],
sub_label=("Rex", 0.95),
),
self.tracked(
"c1",
"car",
5,
start_time=5,
zones=["driveway"],
sub_label=("fedex", 0.9),
),
],
),
(2 + self.alert_cutoff, []),
)
ends = [r["after"] for r in self.reviews() if r["type"] == "end"]
self.assertEqual(len(ends), 2)
alert_end, detection = ends
self.assertEqual(alert_end["data"]["objects"], ["person"])
self.assertEqual(alert_end["data"]["sub_labels"], [])
self.assertEqual(alert_end["data"]["zones"], [])
self.assertEqual(detection["severity"], "detection")
# attributes replace the label, other sub labels verify it
self.assertCountEqual(detection["data"]["objects"], ["dog-verified", "fedex"])
self.assertEqual(detection["data"]["verified_objects"], ["dog-verified"])
self.assertEqual(detection["data"]["sub_labels"], ["Rex"])
self.assertCountEqual(detection["data"]["zones"], ["yard", "driveway"])
if __name__ == "__main__":
unittest.main()