File size: 3,575 Bytes
11fab85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
"""單幀幾何特徵(純函式:無狀態、無 I/O)。

座標系為影像像素、y 向下。只有置信度 ≥ ``kpt_conf_min`` 的關鍵點視為可見;
肩或髖完全不可見時整幀標記 invalid(軀幹是所有特徵的基準,不硬猜)。
時間域的平滑、正規化與速度計算不在此處——見 ``engine``。
"""

from __future__ import annotations

import math
from dataclasses import dataclass

import numpy as np

# COCO 17-keypoint 索引(ultralytics pose 模型輸出順序)
NOSE = 0
L_SHOULDER, R_SHOULDER = 5, 6
L_HIP, R_HIP = 11, 12
L_ANKLE, R_ANKLE = 15, 16

_EPS = 1e-6


@dataclass
class FrameGeometry:
    """一幀、一個人的原始幾何量(像素單位,未平滑、未正規化)。"""

    valid: bool
    theta_deg: float = float("nan")  # 軀幹傾角:0=直立、90=橫臥(頭低於髖時 clamp 90)
    torso_len: float = float("nan")  # 肩中點-髖中點距離(像素);引擎以其滑動中位數 L̃ 作尺度
    bbox_aspect: float = float("nan")  # bbox 寬/高
    hip_y: float = float("nan")  # 髖中點 y(像素)
    ankle_valid: bool = False
    hip_ankle_gap: float = float("nan")  # 踝中點 y − 髖中點 y(>0 = 踝在髖下方)


def _visible_mid(
    kpts_xy: np.ndarray, kpts_conf: np.ndarray, idxs: tuple[int, int], conf_min: float
) -> np.ndarray | None:
    # NaN 座標即使 conf 高也視為不可見:NaN 一旦進入中位數/差分緩衝會汙染整條管線
    pts = [
        kpts_xy[i]
        for i in idxs
        if kpts_conf[i] >= conf_min and np.all(np.isfinite(kpts_xy[i]))
    ]
    if not pts:
        return None
    return np.mean(np.asarray(pts, dtype=np.float64), axis=0)


def compute_frame_geometry(
    kpts_xy: np.ndarray,
    kpts_conf: np.ndarray,
    bbox: np.ndarray,
    kpt_conf_min: float,
) -> FrameGeometry:
    """由 17 個關鍵點與 bbox 計算單幀幾何特徵。

    Args:
        kpts_xy: 形狀 (17, 2) 或展平 (34,) 的像素座標。
        kpts_conf: 形狀 (17,) 的置信度;整列 -1.0 為「模型未輸出 conf」哨兵,
            必然低於 ``kpt_conf_min``,自動導致 invalid。
        bbox: (x1, y1, x2, y2)。
        kpt_conf_min: 關鍵點可見門檻。
    """
    kpts_xy = np.asarray(kpts_xy, dtype=np.float64).reshape(-1, 2)
    kpts_conf = np.asarray(kpts_conf, dtype=np.float64).reshape(-1)
    bbox = np.asarray(bbox, dtype=np.float64).reshape(-1)

    shoulder = _visible_mid(kpts_xy, kpts_conf, (L_SHOULDER, R_SHOULDER), kpt_conf_min)
    hip = _visible_mid(kpts_xy, kpts_conf, (L_HIP, R_HIP), kpt_conf_min)
    if shoulder is None or hip is None:
        return FrameGeometry(valid=False)

    dx = hip[0] - shoulder[0]
    dy = hip[1] - shoulder[1]
    torso_len = math.hypot(dx, dy)
    if not math.isfinite(torso_len) or torso_len < _EPS:
        return FrameGeometry(valid=False)

    if dy < 0:
        # 髖高於肩(倒立/翻滾):傾角語意上已是「非直立」,clamp 為 90°
        theta = 90.0
    else:
        theta = math.degrees(math.atan2(abs(dx), dy))

    w = bbox[2] - bbox[0]
    h = bbox[3] - bbox[1]
    aspect = float(w / h) if (math.isfinite(w) and h > _EPS) else float("nan")

    ankle = _visible_mid(kpts_xy, kpts_conf, (L_ANKLE, R_ANKLE), kpt_conf_min)
    ankle_valid = ankle is not None
    gap = float(ankle[1] - hip[1]) if ankle_valid else float("nan")

    return FrameGeometry(
        valid=True,
        theta_deg=float(theta),
        torso_len=float(torso_len),
        bbox_aspect=aspect,
        hip_y=float(hip[1]),
        ankle_valid=ankle_valid,
        hip_ankle_gap=gap,
    )