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:
ila
2026-07-21 23:14:05 +08:00
co-authored by Claude Opus 4.8
parent 2eeaba0ab8
commit bf86b5612f
7 changed files with 95 additions and 3 deletions
+17
View File
@@ -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())
+2
View File
@@ -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
+35
View File
@@ -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,
+29
View File
@@ -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),
)