mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-31 02:40:32 +03:00
* keep base path in logout redirect * store manual event sub labels as verified objects Manual events created with a sub label were stored in the review item as "label: sub_label", so review previews and collapsed History cards showed a generic icon, the review label filter missed them, and the sub label never reached sub_labels for chips, notifications, or GenAI prompts. The object processor now publishes the label and sub label separately, and the review maintainer stores them as label-verified plus a sub_labels entry, the same as tracked objects. * only expire snapshots for events that still have one Snapshot expiry selected every event older than snapshot retention, including events whose snapshot was already deleted, so with snapshot retention shorter than alert/detection retention the same missing files were unlinked again every cleanup cycle until the event row expired. That was nearly free on local disk but caused constant metadata load on network shares. The expiry queries now filter on has_snapshot so each snapshot is deleted once.
906 lines
33 KiB
Python
906 lines
33 KiB
Python
"""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
|
|
from frigate.track.object_processing import ManualEventState
|
|
|
|
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, _t=None: (
|
|
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."""
|
|
self.feed_messages(
|
|
*[
|
|
(
|
|
DetectionTypeEnum.video.value,
|
|
(CAMERA, f"{CAMERA}_{frame_time}", frame_time, objects, [], []),
|
|
)
|
|
for frame_time, objects in frames
|
|
]
|
|
)
|
|
|
|
def feed_messages(self, *messages: tuple[str, tuple[Any, ...]]) -> None:
|
|
"""Run the maintainer loop over the given (topic, data) messages."""
|
|
queue = list(messages)
|
|
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"])
|
|
|
|
|
|
class TestManualEvents(ReviewFlowTestCase):
|
|
def manual_event(
|
|
self, event_id: str, frame_time: float, sub_label: str | None = None
|
|
) -> tuple[str, tuple[Any, ...]]:
|
|
"""Build a completed manual event as published by the object processor."""
|
|
return (
|
|
DetectionTypeEnum.api.value,
|
|
(
|
|
CAMERA,
|
|
frame_time,
|
|
{
|
|
"state": ManualEventState.complete,
|
|
"label": "person",
|
|
"sub_label": sub_label,
|
|
"event_id": event_id,
|
|
"end_time": frame_time + 5,
|
|
},
|
|
),
|
|
)
|
|
|
|
def test_sub_label_is_verified_on_new_segment(self) -> None:
|
|
self.feed_messages(self.manual_event("m1", 1, "Bob"))
|
|
|
|
data = self.maintainer.active_review_segments[CAMERA].get_data(False)["data"]
|
|
self.assertEqual(data["objects"], ["person-verified"])
|
|
self.assertEqual(data["verified_objects"], ["person-verified"])
|
|
self.assertEqual(data["sub_labels"], ["Bob"])
|
|
|
|
def test_sub_label_is_verified_on_active_segment(self) -> None:
|
|
self.feed(
|
|
(1, [self.tracked("dog1", "dog", 1)]),
|
|
)
|
|
self.feed_messages(
|
|
self.manual_event("m1", 2, "Bob"), self.manual_event("m2", 2)
|
|
)
|
|
|
|
data = self.maintainer.active_review_segments[CAMERA].get_data(False)["data"]
|
|
self.assertCountEqual(data["objects"], ["dog", "person-verified", "person"])
|
|
self.assertEqual(data["sub_labels"], ["Bob"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|