Files
silver_pose/v1/alerts.py
T
ilaandClaude Opus 4.8 afe7a5e781 feat(v1): add local alerts, screenshot and JSONL event log
AlertDispatcher de-duplicates by event_id and fires each side effect once
per CONFIRMED: annotated screenshot, one JSONL line (config_version,
source_id, no rtsp/credentials), sound and popup via injectable sink.
44 tests pass; real-frame artifact smoke verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:49:20 +08:00

156 lines
5.4 KiB
Python

"""Confirmed-event side effects: annotated screenshot, JSONL log, sound, popup.
Side effects run once per ``event_id``. Screenshot annotation and JSONL writing
are Qt-free and unit-tested; sound and popup are injected sinks so the desktop
adapters (Windows) stay thin. Artifact names use only the event ID and a date
folder — never an RTSP address, credential, or client name.
"""
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Dict, Optional, Sequence, Tuple
import cv2
import numpy as np
from v1.fall_state import FallEvent, FallState
from v1.view_model import MonitorViewState, StatusColor
# Semantic tokens as OpenCV BGR tuples (see docs/ui/silver-pose-ui-ux-spec.md).
_COLOR_BGR: Dict[StatusColor, Tuple[int, int, int]] = {
StatusColor.SUCCESS: (61, 128, 21),
StatusColor.CAUTION: (9, 83, 180),
StatusColor.CRITICAL: (40, 40, 198),
StatusColor.OFFLINE: (139, 116, 100),
}
@dataclass(frozen=True)
class AlertRecord:
event: FallEvent
screenshot_path: Path
log_path: Path
created_at_utc: str
class AlertSink:
"""Injectable output for sound and popup. Default implementation is silent."""
def play_sound(self, event: FallEvent) -> None: # pragma: no cover - adapter
pass
def show_popup(self, record: AlertRecord) -> None: # pragma: no cover - adapter
pass
def annotate_frame(image: np.ndarray, view: MonitorViewState) -> np.ndarray:
"""Return a copy of ``image`` with boxes, skeleton, labels and a state border."""
canvas = image.copy()
for overlay in view.people:
color = _COLOR_BGR[overlay.color]
left, top, right, bottom = (int(round(v)) for v in overlay.box_xyxy)
cv2.rectangle(canvas, (left, top), (right, bottom), color, 2)
for start, end in overlay.skeleton_segments:
cv2.line(
canvas,
(int(round(start.x)), int(round(start.y))),
(int(round(end.x)), int(round(end.y))),
color,
2,
)
for point in overlay.keypoints:
if point is not None:
cv2.circle(canvas, (int(round(point.x)), int(round(point.y))), 3, color, -1)
cv2.putText(
canvas,
overlay.label,
(left, max(0, top - 6)),
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
color,
1,
cv2.LINE_AA,
)
if view.highest_state is FallState.CONFIRMED:
height, width = canvas.shape[:2]
cv2.rectangle(
canvas, (0, 0), (width - 1, height - 1), _COLOR_BGR[StatusColor.CRITICAL], 6
)
return canvas
class EventArtifactWriter:
"""Persist one annotated screenshot and one JSONL line per confirmed event."""
def __init__(
self,
event_dir: Path,
source_id: str,
clock: Optional[Callable[[], datetime]] = None,
) -> None:
self._event_dir = Path(event_dir)
self._source_id = source_id
self._clock = clock or (lambda: datetime.now(timezone.utc))
def write(
self, event: FallEvent, image: np.ndarray, view: MonitorViewState
) -> AlertRecord:
created = self._clock().astimezone(timezone.utc)
day_dir = self._event_dir / created.strftime("%Y%m%d")
day_dir.mkdir(parents=True, exist_ok=True)
screenshot_path = day_dir / "{0}.png".format(event.event_id)
annotated = annotate_frame(image, view)
if not cv2.imwrite(str(screenshot_path), annotated):
raise IOError("failed to save screenshot: {0}".format(screenshot_path))
log_path = day_dir / "events.jsonl"
record = {
"event_id": event.event_id,
"track_id": event.track_id,
"config_version": event.config_version,
"source_id": self._source_id,
"state": event.state.value,
"confirmed_at_utc": created.isoformat(),
"suspected_at_monotonic": event.suspected_at_monotonic,
"confirmed_at_monotonic": event.confirmed_at_monotonic,
"latency_seconds": event.latency_seconds,
"screenshot": screenshot_path.relative_to(self._event_dir).as_posix(),
}
with log_path.open("a", encoding="utf-8") as stream:
stream.write(json.dumps(record, ensure_ascii=False) + "\n")
return AlertRecord(
event=event,
screenshot_path=screenshot_path,
log_path=log_path,
created_at_utc=record["confirmed_at_utc"],
)
class AlertDispatcher:
"""De-duplicate confirmed events and fire each side effect exactly once."""
def __init__(self, writer: EventArtifactWriter, sink: Optional[AlertSink] = None) -> None:
self._writer = writer
self._sink = sink or AlertSink()
self._seen_event_ids: set = set()
def dispatch(
self,
events: Sequence[FallEvent],
image: Optional[np.ndarray],
view: MonitorViewState,
) -> Sequence[AlertRecord]:
records = []
for event in events:
if event.event_id in self._seen_event_ids or image is None:
continue
self._seen_event_ids.add(event.event_id)
record = self._writer.write(event, image, view)
self._sink.play_sound(event)
self._sink.show_popup(record)
records.append(record)
return tuple(records)