feat(v1): per-frame fall decision diagnostics overlay
PersonAnalysis now carries the policy Evidence; view_model builds a Qt-free per-person diagnostic line (acc/horiz/ang/rapid/cand/state, or the reject reason) and VideoView overlays it in a corner. Read-only, no decision change; locates where a real fall stops. 67 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -123,8 +123,25 @@ class VideoView(QtWidgets.QWidget):
|
||||
painter.drawImage(target, self._image)
|
||||
if self._view is not None:
|
||||
self._draw_overlays(painter, target, self._image)
|
||||
self._draw_diagnostics(painter)
|
||||
painter.end()
|
||||
|
||||
def _draw_diagnostics(self, painter: QtGui.QPainter) -> None:
|
||||
lines = list(self._view.diagnostics)
|
||||
if not lines:
|
||||
return
|
||||
painter.setFont(QtGui.QFont("Consolas", 9))
|
||||
metrics = painter.fontMetrics()
|
||||
line_h = metrics.height()
|
||||
box_w = max(metrics.width(line) for line in lines) + 12
|
||||
box_h = line_h * len(lines) + 8
|
||||
painter.fillRect(QtCore.QRect(6, 6, box_w, box_h), QtGui.QColor(0, 0, 0, 160))
|
||||
painter.setPen(QtGui.QColor("#9BE9A8"))
|
||||
y = 6 + line_h
|
||||
for line in lines:
|
||||
painter.drawText(12, y, line)
|
||||
y += line_h
|
||||
|
||||
def _fitted_rect(self, image: QtGui.QImage) -> QtCore.QRect:
|
||||
widget = self.rect()
|
||||
scale = min(widget.width() / image.width(), widget.height() / image.height())
|
||||
|
||||
@@ -29,6 +29,7 @@ class PersonAnalysis:
|
||||
tracked_pose: TrackedPersonPose
|
||||
pose_evidence: PoseEvidence
|
||||
state: FallState
|
||||
evidence: Evidence
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -129,6 +130,7 @@ class FallPipeline:
|
||||
tracked_pose=tracked,
|
||||
pose_evidence=pose_evidence,
|
||||
state=self._state_machine.state_of(tracked.track_id),
|
||||
evidence=evidence,
|
||||
)
|
||||
)
|
||||
self._active_track_ids = current_ids
|
||||
|
||||
@@ -84,6 +84,41 @@ def test_confirmed_event_becomes_a_badge():
|
||||
assert view.events[0].latency_seconds == 1.8
|
||||
|
||||
|
||||
def test_person_diagnostic_line_shows_decision_fields():
|
||||
from v1.view_model import person_diagnostic_line
|
||||
|
||||
pose_evidence = SimpleNamespace(
|
||||
accepted=True, horizontal_pose=False, rapid_vertical_change=False,
|
||||
horizontal_angle_degrees=72.0, reason="accepted",
|
||||
)
|
||||
evidence = SimpleNamespace(is_fall_candidate=False, is_recovery_candidate=False)
|
||||
person = SimpleNamespace(
|
||||
tracked_pose=SimpleNamespace(track_id="P-0001"),
|
||||
state=FallState.NORMAL, pose_evidence=pose_evidence, evidence=evidence,
|
||||
)
|
||||
|
||||
assert person_diagnostic_line(person) == "P-0001 NORMAL acc=1 horiz=0 ang=72deg rapid=0 cand=0"
|
||||
|
||||
|
||||
def test_person_diagnostic_line_shows_reject_reason():
|
||||
from v1.view_model import person_diagnostic_line
|
||||
|
||||
pose_evidence = SimpleNamespace(accepted=False, reason="required_joint_low_confidence")
|
||||
person = SimpleNamespace(
|
||||
tracked_pose=SimpleNamespace(track_id="P-0002"),
|
||||
state=FallState.NORMAL, pose_evidence=pose_evidence, evidence=None,
|
||||
)
|
||||
|
||||
assert person_diagnostic_line(person) == "P-0002 NORMAL acc=0 required_joint_low_confidence"
|
||||
|
||||
|
||||
def test_build_monitor_view_includes_a_diagnostic_per_person():
|
||||
view = build_monitor_view(_analysis(people=[_person("P-0001", FallState.NORMAL)]))
|
||||
|
||||
assert len(view.diagnostics) == 1
|
||||
assert view.diagnostics[0].startswith("P-0001 NORMAL")
|
||||
|
||||
|
||||
def _initial():
|
||||
return {
|
||||
"keypoint_confidence_threshold": 0.4,
|
||||
|
||||
@@ -99,6 +99,34 @@ class MonitorViewState:
|
||||
people: Tuple[PersonOverlay, ...]
|
||||
events: Tuple[EventBadge, ...]
|
||||
highest_state: FallState
|
||||
diagnostics: Tuple[str, ...] = ()
|
||||
|
||||
|
||||
def person_diagnostic_line(person) -> str:
|
||||
"""Compact per-person view of the fall decision, to locate where it stops.
|
||||
|
||||
Shows the quality gate (``acc``), horizontal geometry (``horiz``/``ang``),
|
||||
the rapid-drop signal (``rapid``), the policy candidate (``cand``) and the
|
||||
state. When the pose is rejected it shows the reason instead.
|
||||
"""
|
||||
|
||||
track_id = person.tracked_pose.track_id
|
||||
state = person.state.value
|
||||
pose_evidence = person.pose_evidence
|
||||
if pose_evidence is None or not pose_evidence.accepted:
|
||||
reason = getattr(pose_evidence, "reason", "no_evidence")
|
||||
return "{0} {1} acc=0 {2}".format(track_id, state, reason)
|
||||
angle = pose_evidence.horizontal_angle_degrees
|
||||
angle_text = "{0:.0f}deg".format(angle) if angle is not None else "-"
|
||||
candidate = int(getattr(person.evidence, "is_fall_candidate", False))
|
||||
return "{0} {1} acc=1 horiz={2} ang={3} rapid={4} cand={5}".format(
|
||||
track_id,
|
||||
state,
|
||||
int(pose_evidence.horizontal_pose),
|
||||
angle_text,
|
||||
int(pose_evidence.rapid_vertical_change),
|
||||
candidate,
|
||||
)
|
||||
|
||||
|
||||
def build_monitor_view(analysis, keypoint_min_confidence: float = 0.4) -> MonitorViewState:
|
||||
@@ -128,6 +156,7 @@ def build_monitor_view(analysis, keypoint_min_confidence: float = 0.4) -> Monito
|
||||
people=overlays,
|
||||
events=events,
|
||||
highest_state=_highest_state(overlays),
|
||||
diagnostics=tuple(person_diagnostic_line(person) for person in analysis.people),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user