feat(v1): add pose quality evidence
This commit is contained in:
@@ -32,8 +32,8 @@ V1 的同一数据流既可接 RTSP,也可回放本地录像。V2 复用同一
|
||||
| 配置 | `v1/config.py` | 解析示例和本地配置,校验非敏感字段 | 保存真实凭证 |
|
||||
| 视频源 | `v1/video_source.py` | 打开、读取、重连 RTSP 或录像;输出帧、单调回放时间戳和显式来源状态 | Pose、报警 |
|
||||
| Pose 适配器 | `v1/pose.py` | 校验锁定模型的 SHA-256、pose/person/17×3 契约,统一返回 box、关键点、置信度 | 跟踪、摔倒业务结论 |
|
||||
| 跟踪 | `v1/tracking.py` | 为连续人员输出 `track_id` | 根据姿态报警 |
|
||||
| 质量与证据 | `v1/evidence.py` | 过滤低质量点,计算水平姿态、下移和持续性证据 | GUI 状态 |
|
||||
| 跟踪 | `v1/tracking.py` | 以归一化 box 中心距离为连续人员输出稳定 `track_id` | 根据姿态报警 |
|
||||
| 质量与证据 | `v1/evidence.py` | 拒绝缺失肩/髋/膝/踝的姿态,计算水平姿态和躯干归一化下移证据 | GUI 状态、确认事件 |
|
||||
| 状态机 | `v1/fall_state.py` | 管理每个 ID 的 NORMAL、SUSPECT、CONFIRMED、RECOVERING | 播放声音或存文件 |
|
||||
| 报警工件 | `v1/alerts.py` | 对确认事件去重、播放声音、保存截图、写日志 | 推理或事件计算 |
|
||||
| PyQt UI | `v1/gui.py` | 渲染帧、骨架、状态、设置和弹窗 | 直接读 RTSP 或写判定规则 |
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
| T-101 | 创建 V1 包、依赖清单、示例配置和忽略规则 | T-000 | `v1/` 可导入;真实 RTSP 凭证被拒绝提交;`pytest` 能运行。 | DONE |
|
||||
| T-102 | 实现可重连的视频源与录像回放适配器 | T-101 | 有效本地录像可按时间戳产帧;无效源进入连接错误状态且不崩溃。 | DONE |
|
||||
| T-103 | 实现 Pose 适配器与模型来源校验 | T-102 | 输出 person box、17 点和置信度;错误模型或哈希不符时给出明确错误。 | DONE |
|
||||
| T-104 | 实现人员跟踪与姿态质量门控 | T-103 | 连续人员维持 ID;低质量、缺失膝踝或空帧不会产生倒地候选。 | DOING |
|
||||
| T-104 | 实现人员跟踪与姿态质量门控 | T-103 | 连续人员维持 ID;低质量、缺失膝踝或空帧不会产生倒地候选。 | DONE |
|
||||
| T-105 | 实现按 ID 的时序摔倒状态机 | T-104 | 正例在配置秒数内确认;坐下、弯腰、短时低姿态回到 NORMAL;事件副作用只触发一次。 | TODO |
|
||||
|
||||
## Phase 2 · V1 演示闭环
|
||||
|
||||
@@ -245,7 +245,7 @@ git commit -m "feat(v1): add verified pose adapter"
|
||||
- Create: `v1/tracking.py`
|
||||
- Create: `v1/tests/test_evidence.py`
|
||||
|
||||
- [ ] **Step 1: Write quality tests**
|
||||
- [x] **Step 1: Write quality tests**
|
||||
|
||||
```python
|
||||
def test_missing_ankles_rejects_pose(person_pose_without_ankles):
|
||||
@@ -258,12 +258,12 @@ def test_horizontal_body_is_evidence_not_event(horizontal_pose):
|
||||
assert evidence.horizontal_pose is True
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
- [x] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `python -m pytest v1/tests/test_evidence.py -v`
|
||||
Expected: FAIL because quality and evidence functions are missing.
|
||||
|
||||
- [ ] **Step 3: Implement floating-point evidence**
|
||||
- [x] **Step 3: Implement floating-point evidence**
|
||||
|
||||
```python
|
||||
def assess_pose_quality(pose: PersonPose, threshold: float) -> PoseQuality:
|
||||
@@ -275,12 +275,12 @@ def assess_pose_quality(pose: PersonPose, threshold: float) -> PoseQuality:
|
||||
|
||||
Use float coordinates, clamped cosine inputs, torso-normalized vertical motion, and no boolean alarm result in this module.
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
- [x] **Step 4: Run tests**
|
||||
|
||||
Run: `python -m pytest v1/tests/test_evidence.py -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```powershell
|
||||
git add v1/evidence.py v1/tracking.py v1/tests/test_evidence.py docs progress.md
|
||||
|
||||
+10
-1
@@ -54,6 +54,15 @@ PoseQuality = {
|
||||
reason: string,
|
||||
visible_joint_count: int
|
||||
}
|
||||
PoseEvidence = {
|
||||
accepted: bool,
|
||||
horizontal_pose: bool,
|
||||
rapid_vertical_change: bool,
|
||||
horizontal_angle_degrees: float | null,
|
||||
hip_center_y: float | null,
|
||||
torso_length: float | null,
|
||||
reason: string
|
||||
}
|
||||
FallEvent = {
|
||||
event_id: string,
|
||||
source_id: string,
|
||||
@@ -97,7 +106,7 @@ FramePacket = {
|
||||
| --- | --- | --- | --- |
|
||||
| `source.connected` | 视频源 | `source_id`、时间 | UI 显示在线。 |
|
||||
| `source.error` | 视频源 | `source_id`、错误码、可重试标记 | UI 显示异常;不报警。 |
|
||||
| `person.updated` | 跟踪与 Pose | `PersonPose`、`PoseQuality`、状态 | UI 绘制骨架与 ID。 |
|
||||
| `person.updated` | 跟踪与 Pose | `TrackedPersonPose`、`PoseQuality`、`PoseEvidence` | UI 绘制骨架与 ID。 |
|
||||
| `fall.suspected` | 状态机 | `track_id`、开始时间 | UI 显示黄色疑似状态。 |
|
||||
| `fall.confirmed` | 状态机 | `FallEvent` | 红色叠加、声音、弹窗、截图、JSONL。 |
|
||||
| `fall.recovered` | 状态机 | `track_id`、时间 | UI 恢复绿色正常状态。 |
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-21
|
||||
- 阶段:V1 工程化起步;T-104 进行中。
|
||||
- 阶段:V1 工程化起步;T-104 已验收,等待 T-105。
|
||||
- 已验证环境:Windows PowerShell;Python 3.8.10;Ultralytics 8.3.205;PyQt5 可导入。
|
||||
- 旧生产基线:`demo/main.py`、`demo/fall_detection_gui.py`、`demo/detect_fall.py`、`demo/best.pt`。
|
||||
- V1 代码:已建立安全配置基线、OpenCV 回放/重连适配器,以及 `v1/pose.py` 的 SHA-256 锁定 Pose 适配器;真实模型 smoke 已验证首帧输出 person box 和 17 点。跟踪、证据、状态机和 GUI 尚未实现。
|
||||
- V1 代码:已建立安全配置、视频源与 Pose 适配器,以及 `v1/tracking.py` 的稳定人员 ID 和 `v1/evidence.py` 的质量/几何证据;状态机和 GUI 尚未实现。
|
||||
- V2 代码:`v2/` 目录存在但尚无实现。
|
||||
- 非代码设计工件:docs/ui/silver-pose-ui-ux-spec.md、docs/ui/2026-07-20-html-prototype-plan.md、docs/ui/silver-pose-v1-prototype.html 与 docs/ui/silver-pose-v2-prototype.html 已建立。v2 HTML 是符合正式浅色 Windows 规范的当前视觉参考:浅灰蓝底、白色卡片,红色只表示确认摔倒、其弹窗和事件证据;文件名中的 v2 只表示原型设计修订,不能理解为 Go V2 实现已开始。v1 HTML 保留为历史深色对照。两者均使用顶部双 Tab、设置草稿与状态交互,且画面、事件和时间都是模拟数据,不连接真实摄像头、模型或网络,也不改变 Phase 1 任务顺序。
|
||||
- 测试:`python -m compileall -q demo` 已通过;`python -m pytest v1/tests -v` 当前有 9 项配置/视频源/Pose 测试并已通过。`demo/1.mp4` 的首两帧回放时间戳已验证为 0.000000 与 0.033333 秒,首帧 Pose smoke 得到 2 名人员、每人 17 点。`init.ps1` 会检查运行时依赖、编译旧基线并运行 V1 测试,但不会安装软件包。
|
||||
- 测试:`python -m compileall -q demo` 已通过;`python -m pytest v1/tests -v` 当前有 14 项配置/视频源/Pose/跟踪/证据测试并已通过。`demo/1.mp4` 的首两帧回放时间戳已验证为 0.000000 与 0.033333 秒,首帧 Pose smoke 得到 2 名人员、每人 17 点。`init.ps1` 会检查运行时依赖、编译旧基线并运行 V1 测试,但不会安装软件包。
|
||||
- 模型:`demo/best.pt` 可加载为 YOLO Pose,类别 `person`,`kpt_shape=[17, 3]`;与 `D:\PythonP\fall_detection\best.pt` 哈希一致。
|
||||
- 当前标准启动:`./init.ps1`。
|
||||
- 当前标准验证:`python -m compileall -q demo`。
|
||||
@@ -32,9 +32,9 @@
|
||||
|
||||
## 任务状态
|
||||
|
||||
- 已完成:T-000(Harness 文档与旧基线快照)、T-101(V1 安全配置基线)、T-102(视频源与录像回放)、T-103(Pose 适配器与模型校验)。
|
||||
- 正在进行:T-104(实现人员跟踪与姿态质量门控)。
|
||||
- 下一个可领取:完成 T-104 后为 T-105。
|
||||
- 已完成:T-000(Harness 文档与旧基线快照)、T-101(V1 安全配置基线)、T-102(视频源与录像回放)、T-103(Pose 适配器与模型校验)、T-104(跟踪与姿态质量证据)。
|
||||
- 正在进行:无。
|
||||
- 下一个可领取:T-105。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
|
||||
@@ -134,3 +134,12 @@
|
||||
- 阻塞:无。
|
||||
- 决策:质量与证据模块只能返回质量/几何事实,不能返回报警或直接创建事件;空帧和低质量姿态不可推进倒地候选。
|
||||
- 下一步:写入 T-104 的失败测试。
|
||||
|
||||
## 【2026-07-21】T-104 实现人员跟踪与姿态质量门控(完成)
|
||||
|
||||
- 状态:DONE
|
||||
- 变更:新增 `v1/tracking.py`,以归一化框中心距离为连续人员分配稳定 ID;新增 `v1/evidence.py`,要求肩、髋、膝、踝关键点全部超过阈值,再输出水平躯干角度和相对躯干长度归一化的下移证据。
|
||||
- 验证:先运行 `python -m pytest v1/tests/test_evidence.py -v`,确认因缺少 `v1.evidence` 导入失败;实现后证据/跟踪测试 5 passed,完整 V1 测试在本轮最后一次运行时为 14 passed,`python -m compileall -q v1 demo` 通过。
|
||||
- 阻塞:无。
|
||||
- 决策:`PoseEvidence` 只表达质量和几何事实,不能报警或创建事件;空姿态、缺失下肢点或退化躯干都会停止证据积累。T-105 必须以 `track_id`、单调时间和这些证据来确认事件。
|
||||
- 下一步:T-105,先写持续倒地只产生一次事件、短时弯腰不报警与恢复后的新事件边界测试。
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Pose quality and geometric evidence calculations without alarm decisions."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import acos, degrees, hypot
|
||||
from typing import Optional
|
||||
|
||||
from v1.pose import PersonPose
|
||||
|
||||
|
||||
_REQUIRED_JOINTS = (5, 6, 11, 12, 13, 14, 15, 16)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PoseQuality:
|
||||
accepted: bool
|
||||
reason: str
|
||||
visible_joint_count: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PoseEvidence:
|
||||
accepted: bool
|
||||
horizontal_pose: bool
|
||||
rapid_vertical_change: bool
|
||||
horizontal_angle_degrees: Optional[float]
|
||||
hip_center_y: Optional[float]
|
||||
torso_length: Optional[float]
|
||||
reason: str
|
||||
|
||||
|
||||
def assess_pose_quality(
|
||||
pose: Optional[PersonPose], threshold: float
|
||||
) -> PoseQuality:
|
||||
if not 0.0 <= threshold <= 1.0:
|
||||
raise ValueError("threshold must be between 0 and 1")
|
||||
if pose is None:
|
||||
return PoseQuality(False, "missing_pose", 0)
|
||||
if len(pose.keypoints) != 17:
|
||||
return PoseQuality(False, "invalid_keypoint_count", 0)
|
||||
visible_count = sum(
|
||||
point.confidence >= threshold for point in pose.keypoints
|
||||
)
|
||||
if any(pose.keypoints[index].confidence < threshold for index in _REQUIRED_JOINTS):
|
||||
return PoseQuality(False, "required_joint_low_confidence", visible_count)
|
||||
return PoseQuality(True, "accepted", visible_count)
|
||||
|
||||
|
||||
def extract_evidence(
|
||||
pose: Optional[PersonPose],
|
||||
quality: PoseQuality,
|
||||
previous: Optional[PoseEvidence],
|
||||
rapid_drop_torso_ratio: float = 0.5,
|
||||
horizontal_angle_threshold_degrees: float = 35.0,
|
||||
) -> PoseEvidence:
|
||||
"""Return geometric facts; a later state machine decides whether to alarm."""
|
||||
|
||||
if not quality.accepted or pose is None:
|
||||
return PoseEvidence(False, False, False, None, None, None, quality.reason)
|
||||
if rapid_drop_torso_ratio < 0:
|
||||
raise ValueError("rapid_drop_torso_ratio must be non-negative")
|
||||
shoulder_x, shoulder_y = _midpoint(pose, 5, 6)
|
||||
hip_x, hip_y = _midpoint(pose, 11, 12)
|
||||
vector_x = hip_x - shoulder_x
|
||||
vector_y = hip_y - shoulder_y
|
||||
torso_length = hypot(vector_x, vector_y)
|
||||
if torso_length == 0:
|
||||
return PoseEvidence(False, False, False, None, hip_y, 0.0, "degenerate_torso")
|
||||
cosine_to_horizontal = max(-1.0, min(1.0, abs(vector_x) / torso_length))
|
||||
horizontal_angle = degrees(acos(cosine_to_horizontal))
|
||||
horizontal_pose = horizontal_angle <= horizontal_angle_threshold_degrees
|
||||
rapid_vertical_change = False
|
||||
if previous is not None and previous.accepted and previous.hip_center_y is not None:
|
||||
rapid_vertical_change = (
|
||||
hip_y - previous.hip_center_y >= rapid_drop_torso_ratio * torso_length
|
||||
)
|
||||
return PoseEvidence(
|
||||
accepted=True,
|
||||
horizontal_pose=horizontal_pose,
|
||||
rapid_vertical_change=rapid_vertical_change,
|
||||
horizontal_angle_degrees=horizontal_angle,
|
||||
hip_center_y=hip_y,
|
||||
torso_length=torso_length,
|
||||
reason="accepted",
|
||||
)
|
||||
|
||||
|
||||
def _midpoint(pose: PersonPose, first_index: int, second_index: int):
|
||||
first = pose.keypoints[first_index]
|
||||
second = pose.keypoints[second_index]
|
||||
return ((first.x + second.x) / 2.0, (first.y + second.y) / 2.0)
|
||||
@@ -0,0 +1,76 @@
|
||||
from v1.pose import Keypoint, PersonPose
|
||||
from v1.evidence import assess_pose_quality, extract_evidence
|
||||
from v1.tracking import PersonTracker
|
||||
|
||||
|
||||
def _pose(box=(20.0, 20.0, 60.0, 140.0), horizontal=False, missing_ankles=False):
|
||||
points = [Keypoint(float(index), float(index), 0.9) for index in range(17)]
|
||||
if horizontal:
|
||||
points[5] = Keypoint(20.0, 50.0, 0.9)
|
||||
points[6] = Keypoint(30.0, 50.0, 0.9)
|
||||
points[11] = Keypoint(70.0, 53.0, 0.9)
|
||||
points[12] = Keypoint(80.0, 53.0, 0.9)
|
||||
if missing_ankles:
|
||||
points[15] = Keypoint(35.0, 120.0, 0.1)
|
||||
points[16] = Keypoint(45.0, 120.0, 0.1)
|
||||
return PersonPose(box_xyxy=box, box_confidence=0.9, keypoints=tuple(points))
|
||||
|
||||
|
||||
def test_tracker_keeps_id_for_nearby_person_in_next_frame():
|
||||
tracker = PersonTracker(max_match_distance_ratio=0.2)
|
||||
|
||||
first = tracker.update([_pose()], detected_at_monotonic=1.0, frame_size=(200, 200))
|
||||
second = tracker.update(
|
||||
[_pose(box=(23.0, 22.0, 63.0, 142.0))],
|
||||
detected_at_monotonic=1.1,
|
||||
frame_size=(200, 200),
|
||||
)
|
||||
|
||||
assert first[0].track_id == second[0].track_id
|
||||
|
||||
|
||||
def test_missing_ankles_rejects_pose():
|
||||
quality = assess_pose_quality(_pose(missing_ankles=True), threshold=0.4)
|
||||
|
||||
assert quality.accepted is False
|
||||
assert quality.reason == "required_joint_low_confidence"
|
||||
|
||||
|
||||
def test_horizontal_body_is_evidence_not_event():
|
||||
pose = _pose(horizontal=True)
|
||||
quality = assess_pose_quality(pose, threshold=0.4)
|
||||
|
||||
evidence = extract_evidence(pose, quality=quality, previous=None)
|
||||
|
||||
assert evidence.accepted is True
|
||||
assert evidence.horizontal_pose is True
|
||||
assert evidence.rapid_vertical_change is False
|
||||
|
||||
|
||||
def test_missing_pose_cannot_create_usable_evidence():
|
||||
quality = assess_pose_quality(None, threshold=0.4)
|
||||
|
||||
evidence = extract_evidence(None, quality=quality, previous=None)
|
||||
|
||||
assert quality.accepted is False
|
||||
assert evidence.accepted is False
|
||||
|
||||
|
||||
def test_large_hip_drop_is_normalized_as_rapid_vertical_evidence():
|
||||
previous_pose = _pose()
|
||||
current_points = list(previous_pose.keypoints)
|
||||
current_points[11] = Keypoint(11.0, 100.0, 0.9)
|
||||
current_points[12] = Keypoint(12.0, 100.0, 0.9)
|
||||
current_pose = PersonPose(
|
||||
box_xyxy=previous_pose.box_xyxy,
|
||||
box_confidence=previous_pose.box_confidence,
|
||||
keypoints=tuple(current_points),
|
||||
)
|
||||
previous_quality = assess_pose_quality(previous_pose, threshold=0.4)
|
||||
current_quality = assess_pose_quality(current_pose, threshold=0.4)
|
||||
previous = extract_evidence(previous_pose, quality=previous_quality, previous=None)
|
||||
|
||||
evidence = extract_evidence(current_pose, quality=current_quality, previous=previous)
|
||||
|
||||
assert evidence.accepted is True
|
||||
assert evidence.rapid_vertical_change is True
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
"""Lightweight deterministic person tracking for the single-camera V1 flow."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import hypot
|
||||
from typing import Dict, Sequence, Tuple
|
||||
|
||||
from v1.pose import PersonPose
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrackedPersonPose:
|
||||
track_id: str
|
||||
detected_at_monotonic: float
|
||||
pose: PersonPose
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Track:
|
||||
center: Tuple[float, float]
|
||||
last_seen_at: float
|
||||
|
||||
|
||||
class PersonTracker:
|
||||
"""Assign stable IDs by nearest normalized box center across adjacent frames."""
|
||||
|
||||
def __init__(
|
||||
self, max_match_distance_ratio: float = 0.2, max_age_seconds: float = 2.0
|
||||
) -> None:
|
||||
if not 0.0 < max_match_distance_ratio <= 1.0:
|
||||
raise ValueError("max_match_distance_ratio must be in (0, 1]")
|
||||
if max_age_seconds <= 0:
|
||||
raise ValueError("max_age_seconds must be positive")
|
||||
self._max_match_distance_ratio = max_match_distance_ratio
|
||||
self._max_age_seconds = max_age_seconds
|
||||
self._tracks: Dict[str, _Track] = {}
|
||||
self._next_track_number = 1
|
||||
|
||||
def update(
|
||||
self,
|
||||
poses: Sequence[PersonPose],
|
||||
detected_at_monotonic: float,
|
||||
frame_size: Tuple[int, int],
|
||||
) -> Sequence[TrackedPersonPose]:
|
||||
width, height = frame_size
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("frame_size must contain positive width and height")
|
||||
self._expire_tracks(detected_at_monotonic)
|
||||
available_ids = set(self._tracks)
|
||||
tracked = []
|
||||
for pose in poses:
|
||||
center = self._box_center(pose)
|
||||
track_id = self._nearest_available_track(center, available_ids, width, height)
|
||||
if track_id is None:
|
||||
track_id = "P-{0:04d}".format(self._next_track_number)
|
||||
self._next_track_number += 1
|
||||
else:
|
||||
available_ids.remove(track_id)
|
||||
self._tracks[track_id] = _Track(center=center, last_seen_at=detected_at_monotonic)
|
||||
tracked.append(
|
||||
TrackedPersonPose(
|
||||
track_id=track_id,
|
||||
detected_at_monotonic=float(detected_at_monotonic),
|
||||
pose=pose,
|
||||
)
|
||||
)
|
||||
return tuple(tracked)
|
||||
|
||||
def _nearest_available_track(
|
||||
self,
|
||||
center: Tuple[float, float],
|
||||
available_ids: set,
|
||||
width: int,
|
||||
height: int,
|
||||
):
|
||||
closest_id = None
|
||||
closest_distance = None
|
||||
for track_id in available_ids:
|
||||
previous = self._tracks[track_id].center
|
||||
distance = hypot(
|
||||
(center[0] - previous[0]) / float(width),
|
||||
(center[1] - previous[1]) / float(height),
|
||||
)
|
||||
if distance <= self._max_match_distance_ratio and (
|
||||
closest_distance is None or distance < closest_distance
|
||||
):
|
||||
closest_id = track_id
|
||||
closest_distance = distance
|
||||
return closest_id
|
||||
|
||||
def _expire_tracks(self, now: float) -> None:
|
||||
expired_ids = [
|
||||
track_id
|
||||
for track_id, track in self._tracks.items()
|
||||
if now - track.last_seen_at > self._max_age_seconds
|
||||
]
|
||||
for track_id in expired_ids:
|
||||
del self._tracks[track_id]
|
||||
|
||||
@staticmethod
|
||||
def _box_center(pose: PersonPose) -> Tuple[float, float]:
|
||||
left, top, right, bottom = pose.box_xyxy
|
||||
return ((left + right) / 2.0, (top + bottom) / 2.0)
|
||||
Reference in New Issue
Block a user