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:
ila
2026-07-21 19:49:20 +08:00
co-authored by Claude Opus 4.8
parent 861532ffb0
commit afe7a5e781
8 changed files with 347 additions and 7 deletions
+155
View File
@@ -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)
+9
View File
@@ -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)
+27
View File
@@ -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."""
+123
View File
@@ -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