From 444c01407a4ed050c21239140e2a197243c5809e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Sat, 11 Jul 2026 20:18:03 +0900 Subject: [PATCH] feat(edge): pose geometry utils (torso angle, com, normalization) --- edge/geometry.py | 55 ++++++++++++++++++++++++++++++++++++++++++ tests/test_geometry.py | 39 ++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 edge/geometry.py create mode 100644 tests/test_geometry.py diff --git a/edge/geometry.py b/edge/geometry.py new file mode 100644 index 0000000..9e4e86a --- /dev/null +++ b/edge/geometry.py @@ -0,0 +1,55 @@ +import math + +from edge.models import ( + Keypoint, BBox, NOSE, L_SHOULDER, R_SHOULDER, L_HIP, R_HIP, CORE_JOINTS, +) + + +def _mean(points: list[tuple[float, float]]) -> tuple[float, float]: + n = len(points) + return (sum(p[0] for p in points) / n, sum(p[1] for p in points) / n) + + +def com(keypoints: list[Keypoint]) -> tuple[float, float]: + pts = [(keypoints[i].x, keypoints[i].y) for i in CORE_JOINTS] + return _mean(pts) + + +def _shoulder_center(kps: list[Keypoint]) -> tuple[float, float]: + return _mean([(kps[L_SHOULDER].x, kps[L_SHOULDER].y), + (kps[R_SHOULDER].x, kps[R_SHOULDER].y)]) + + +def _hip_center(kps: list[Keypoint]) -> tuple[float, float]: + return _mean([(kps[L_HIP].x, kps[L_HIP].y), + (kps[R_HIP].x, kps[R_HIP].y)]) + + +def torso_angle_deg(keypoints: list[Keypoint]) -> float: + """몸통(어깨중심→엉덩이중심) 벡터가 수직축과 이루는 각. 0=수직, 90=수평.""" + sx, sy = _shoulder_center(keypoints) + hx, hy = _hip_center(keypoints) + vx, vy = hx - sx, hy - sy + # 수직축(0,1) 대비 편차. 위/아래 무관하게 |성분| 사용. + return math.degrees(math.atan2(abs(vx), abs(vy))) if (vx or vy) else 0.0 + + +def head_below_hips(keypoints: list[Keypoint]) -> bool: + _, hy = _hip_center(keypoints) + return keypoints[NOSE].y > hy + + +def visible_count(keypoints: list[Keypoint], thr: float = 0.2) -> int: + return sum(1 for k in keypoints if k.conf >= thr) + + +def core_visible(keypoints: list[Keypoint], thr: float = 0.5) -> bool: + return all(keypoints[i].conf >= thr for i in CORE_JOINTS) + + +def height_of(bbox: BBox) -> float: + return bbox.height + + +def normalize(value: float, bbox_height: float) -> float: + return value / bbox_height if bbox_height else 0.0 diff --git a/tests/test_geometry.py b/tests/test_geometry.py new file mode 100644 index 0000000..565b445 --- /dev/null +++ b/tests/test_geometry.py @@ -0,0 +1,39 @@ +import math +from edge.models import Keypoint, BBox +from edge.geometry import ( + com, torso_angle_deg, head_below_hips, visible_count, + core_visible, normalize, +) + +def _kps(overrides): + kps = [Keypoint(0, 0, 0.0) for _ in range(17)] + for i, (x, y, c) in overrides.items(): + kps[i] = Keypoint(x, y, c) + return kps + +def test_com_is_mean_of_shoulders_and_hips(): + kps = _kps({5: (0, 0, 1), 6: (10, 0, 1), 11: (0, 10, 1), 12: (10, 10, 1)}) + assert com(kps) == (5.0, 5.0) + +def test_torso_angle_upright_near_zero(): + # shoulders above hips (smaller y), vertical torso + kps = _kps({5: (0, 0, 1), 6: (10, 0, 1), 11: (0, 100, 1), 12: (10, 100, 1)}) + assert torso_angle_deg(kps) < 10 + +def test_torso_angle_lying_near_ninety(): + # shoulders and hips at same height, horizontal torso + kps = _kps({5: (0, 50, 1), 6: (0, 55, 1), 11: (100, 50, 1), 12: (100, 55, 1)}) + assert torso_angle_deg(kps) > 80 + +def test_head_below_hips_true_when_nose_lower_on_screen(): + kps = _kps({0: (5, 120, 1), 11: (0, 100, 1), 12: (10, 100, 1)}) + assert head_below_hips(kps) is True + +def test_visible_count_and_core_visible(): + kps = _kps({5: (0, 0, 0.9), 6: (1, 0, 0.9), 11: (0, 1, 0.9), 12: (1, 1, 0.9)}) + assert visible_count(kps, thr=0.2) == 4 + assert core_visible(kps, thr=0.5) is True + +def test_normalize_divides_by_bbox_height(): + assert normalize(30, 60) == 0.5 + assert normalize(30, 0) == 0.0