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,