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>
This commit is contained in:
+155
@@ -0,0 +1,155 @@
|
||||
"""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)
|
||||
@@ -14,6 +14,7 @@ from typing import Optional
|
||||
|
||||
from PyQt5 import QtCore, QtWidgets
|
||||
|
||||
from v1.alerts import AlertDispatcher, EventArtifactWriter
|
||||
from v1.config import AppConfig, EventConfig, load_config
|
||||
from v1.pipeline import FallPipeline
|
||||
from v1.pose import PoseAdapter
|
||||
@@ -93,12 +94,17 @@ class ApplicationController:
|
||||
self.window.monitor.start_requested.connect(self.start)
|
||||
self.window.monitor.stop_requested.connect(self.stop)
|
||||
self._worker: Optional[FrameWorker] = None
|
||||
self._dispatcher: Optional[AlertDispatcher] = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._worker is not None:
|
||||
return
|
||||
from v1.gui import QtAlertSink
|
||||
|
||||
self._draft.start_monitoring()
|
||||
running = _running_config(self._config, self._draft)
|
||||
writer = EventArtifactWriter(running.event_dir, running.source_id)
|
||||
self._dispatcher = AlertDispatcher(writer, QtAlertSink(self.window))
|
||||
worker = FrameWorker(running, self._pose_adapter)
|
||||
worker.frame_ready.connect(self._on_frame)
|
||||
worker.finished.connect(self._on_finished)
|
||||
@@ -115,9 +121,12 @@ class ApplicationController:
|
||||
analysis, self._draft.running_values["keypoint_confidence_threshold"]
|
||||
)
|
||||
self.window.monitor.render_view(analysis.packet.image, view)
|
||||
if self._dispatcher is not None:
|
||||
self._dispatcher.dispatch(analysis.events, analysis.packet.image, view)
|
||||
|
||||
def _on_finished(self) -> None:
|
||||
self._worker = None
|
||||
self._dispatcher = None
|
||||
self.window.monitor.set_running(False)
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Dict, Optional
|
||||
import numpy as np
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
|
||||
from v1.alerts import AlertRecord, AlertSink
|
||||
from v1.view_model import (
|
||||
FIELD_BOUNDS,
|
||||
DraftValidationError,
|
||||
@@ -69,6 +70,32 @@ def bgr_to_qimage(image: np.ndarray) -> QtGui.QImage:
|
||||
).copy()
|
||||
|
||||
|
||||
class QtAlertSink(AlertSink):
|
||||
"""Desktop sound + one non-blocking popup per confirmed event (Windows smoke)."""
|
||||
|
||||
def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
|
||||
self._parent = parent
|
||||
|
||||
def play_sound(self, event) -> None:
|
||||
QtWidgets.QApplication.beep()
|
||||
|
||||
def show_popup(self, record: AlertRecord) -> None:
|
||||
box = QtWidgets.QMessageBox(self._parent)
|
||||
box.setIcon(QtWidgets.QMessageBox.Warning)
|
||||
box.setWindowTitle("确认摔倒")
|
||||
box.setText(
|
||||
"人员 {0} 确认摔倒\n确认延迟 {1:.2f} 秒\n截图:{2}".format(
|
||||
record.event.track_id,
|
||||
record.event.latency_seconds,
|
||||
record.screenshot_path.name,
|
||||
)
|
||||
)
|
||||
box.setStandardButtons(QtWidgets.QMessageBox.Ok)
|
||||
box.button(QtWidgets.QMessageBox.Ok).setText("我已知晓")
|
||||
box.setModal(False)
|
||||
box.show()
|
||||
|
||||
|
||||
class VideoView(QtWidgets.QWidget):
|
||||
"""Paint the latest frame plus box/skeleton/id/state overlays."""
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from v1.alerts import AlertDispatcher, AlertSink, EventArtifactWriter, annotate_frame
|
||||
from v1.fall_state import FallEvent, FallState
|
||||
from v1.view_model import MonitorViewState, PersonOverlay, Point, StatusColor
|
||||
|
||||
|
||||
class RecordingSink(AlertSink):
|
||||
def __init__(self):
|
||||
self.sounds = []
|
||||
self.popups = []
|
||||
|
||||
def play_sound(self, event):
|
||||
self.sounds.append(event.event_id)
|
||||
|
||||
def show_popup(self, record):
|
||||
self.popups.append(record.event.event_id)
|
||||
|
||||
|
||||
def _event(event_id="FALL-000001", track_id="P-0003"):
|
||||
return FallEvent(
|
||||
event_id=event_id,
|
||||
track_id=track_id,
|
||||
config_version="cfg-abc123",
|
||||
suspected_at_monotonic=0.0,
|
||||
confirmed_at_monotonic=1.8,
|
||||
latency_seconds=1.8,
|
||||
)
|
||||
|
||||
|
||||
def _confirmed_view():
|
||||
overlay = PersonOverlay(
|
||||
track_id="P-0003",
|
||||
state=FallState.CONFIRMED,
|
||||
color=StatusColor.CRITICAL,
|
||||
box_xyxy=(20.0, 20.0, 80.0, 180.0),
|
||||
keypoints=(Point(30.0, 40.0), Point(40.0, 40.0)),
|
||||
skeleton_segments=((Point(30.0, 40.0), Point(40.0, 40.0)),),
|
||||
label="P-0003 · CONFIRMED",
|
||||
)
|
||||
return MonitorViewState(
|
||||
connected=True,
|
||||
status_text="在线",
|
||||
status_color=StatusColor.SUCCESS,
|
||||
has_frame=True,
|
||||
people=(overlay,),
|
||||
events=(),
|
||||
highest_state=FallState.CONFIRMED,
|
||||
)
|
||||
|
||||
|
||||
def _writer(tmp_path):
|
||||
fixed = datetime(2026, 7, 21, 8, 30, 0, tzinfo=timezone.utc)
|
||||
return EventArtifactWriter(tmp_path, source_id="lobby-camera-01", clock=lambda: fixed)
|
||||
|
||||
|
||||
def _image():
|
||||
return np.full((200, 240, 3), 30, dtype=np.uint8)
|
||||
|
||||
|
||||
def test_confirmed_event_fires_each_side_effect_once(tmp_path):
|
||||
sink = RecordingSink()
|
||||
dispatcher = AlertDispatcher(_writer(tmp_path), sink)
|
||||
event = _event()
|
||||
|
||||
first = dispatcher.dispatch([event], _image(), _confirmed_view())
|
||||
second = dispatcher.dispatch([event], _image(), _confirmed_view())
|
||||
|
||||
assert len(first) == 1
|
||||
assert second == ()
|
||||
assert sink.sounds == ["FALL-000001"]
|
||||
assert sink.popups == ["FALL-000001"]
|
||||
log_path = tmp_path / "20260721" / "events.jsonl"
|
||||
assert log_path.read_text(encoding="utf-8").count("\n") == 1
|
||||
assert (tmp_path / "20260721" / "FALL-000001.png").is_file()
|
||||
|
||||
|
||||
def test_jsonl_record_has_fields_and_no_source_address(tmp_path):
|
||||
dispatcher = AlertDispatcher(_writer(tmp_path))
|
||||
dispatcher.dispatch([_event()], _image(), _confirmed_view())
|
||||
|
||||
line = (tmp_path / "20260721" / "events.jsonl").read_text(encoding="utf-8").strip()
|
||||
record = json.loads(line)
|
||||
assert record["event_id"] == "FALL-000001"
|
||||
assert record["config_version"] == "cfg-abc123"
|
||||
assert record["source_id"] == "lobby-camera-01"
|
||||
assert record["state"] == "CONFIRMED"
|
||||
assert record["confirmed_at_utc"] == "2026-07-21T08:30:00+00:00"
|
||||
assert record["screenshot"] == "20260721/FALL-000001.png"
|
||||
assert "rtsp" not in line.lower()
|
||||
assert "password" not in line.lower()
|
||||
|
||||
|
||||
def test_missing_image_produces_no_side_effect(tmp_path):
|
||||
sink = RecordingSink()
|
||||
dispatcher = AlertDispatcher(_writer(tmp_path), sink)
|
||||
|
||||
records = dispatcher.dispatch([_event()], None, _confirmed_view())
|
||||
|
||||
assert records == ()
|
||||
assert sink.sounds == []
|
||||
assert not (tmp_path / "20260721").exists()
|
||||
|
||||
|
||||
def test_distinct_events_produce_two_log_lines(tmp_path):
|
||||
dispatcher = AlertDispatcher(_writer(tmp_path))
|
||||
dispatcher.dispatch([_event("FALL-000001")], _image(), _confirmed_view())
|
||||
dispatcher.dispatch([_event("FALL-000002", "P-0009")], _image(), _confirmed_view())
|
||||
|
||||
lines = (tmp_path / "20260721" / "events.jsonl").read_text(encoding="utf-8").strip().split("\n")
|
||||
assert len(lines) == 2
|
||||
|
||||
|
||||
def test_annotate_draws_red_border_only_for_confirmed():
|
||||
image = np.full((60, 80, 3), 30, dtype=np.uint8)
|
||||
confirmed = annotate_frame(image, _confirmed_view())
|
||||
corner = confirmed[0, 0].tolist()
|
||||
assert corner == [40, 40, 198] # critical red in BGR
|
||||
assert confirmed.shape == image.shape
|
||||
Reference in New Issue
Block a user