Same pose stream, new safety axis: musculoskeletal-load prevention. This is an honest 2D reduction of REBA - band exposure TIME, not a precise REBA score. Edge: - geometry: arm_elevation_deg (shoulder->elbow vs vertical-down, best of both arms) and knee_flexion_deg (hip-knee-ankle interior angle, worst of both legs). Both return None when no confident side exists - "unmeasurable" must never read as "0 deg / straight". - detectors/ergonomics.py: per-frame dt accumulation into bands (trunk 20-60 and 60+ deg via the existing torso angle, arm 90+ deg, knee <110 deg), gated on confident core keypoints; detection gaps are dt-capped (2s) so a dropout never counts as exposure. Hourly per-camera report, skipped when nothing was observed (an empty-workshop report is noise). - Engine: tracks in an incident state (FALLING/FALLEN/NO_MOTION) are excluded - a fallen worker's 90-deg trunk is an incident, not an ergonomic exposure. The report event (ergo_report/INFO) carries no track_id and no spatial: a camera-level anonymous aggregate, per the PIPA-first product stance. - run_once: NO snapshots on ergo_report (a frame would turn a statistic into surveillance imagery); capture is skipped entirely when only ergo fired. Server/UI: ergo_report accepted (validator + chk_events_type, edited in the still-undeployed heatmap migration); detail view now renders event metrics with Korean labels and seconds humanized - the ergo report's whole value IS its metrics. Korean type label in list + detail. Verified: pytest 232 pass (10 new) / PHPUnit 135 pass. NOTE: the detail-view visual check is pending - the Chrome extension disconnected mid-verification; harness is staged under the session scratchpad (events/1/view).
136 lines
5.6 KiB
Python
136 lines
5.6 KiB
Python
import math
|
|
|
|
from edge.models import (
|
|
Keypoint, BBox, NOSE, L_SHOULDER, R_SHOULDER, L_ELBOW, R_ELBOW,
|
|
L_HIP, R_HIP, L_KNEE, R_KNEE, L_ANKLE, R_ANKLE, 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], nose_thr: float = 0.2) -> bool:
|
|
# Only trust the nose if the model is confident about it. An unconfident nose (commonly
|
|
# hallucinated at (0,0) / a frame corner) would otherwise assert — or silently suppress —
|
|
# this fall cue on data we don't trust. Same 'visible' gate as visible_count().
|
|
nose = keypoints[NOSE]
|
|
if nose.conf < nose_thr:
|
|
return False
|
|
_, hy = _hip_center(keypoints)
|
|
return nose.y > hy
|
|
|
|
|
|
def arm_elevation_deg(keypoints: list[Keypoint], min_conf: float = 0.5) -> float | None:
|
|
"""어깨→팔꿈치 벡터가 '아래 수직'과 이루는 각의 좌우 최대값. 0=팔 내림, 90=수평 거상,
|
|
180=머리 위. 신뢰 가능한 쪽 팔만 사용; 양쪽 다 불가하면 None (측정 불가 ≠ 0°)."""
|
|
best = None
|
|
for s_i, e_i in ((L_SHOULDER, L_ELBOW), (R_SHOULDER, R_ELBOW)):
|
|
s, e = keypoints[s_i], keypoints[e_i]
|
|
if s.conf < min_conf or e.conf < min_conf:
|
|
continue
|
|
vx, vy = e.x - s.x, e.y - s.y
|
|
if not (vx or vy):
|
|
continue
|
|
# 이미지 y축은 아래로 증가: (0, +1)이 '팔 내림' 기준축.
|
|
ang = math.degrees(math.atan2(abs(vx), vy)) if vy >= 0 else \
|
|
180.0 - math.degrees(math.atan2(abs(vx), -vy))
|
|
best = ang if best is None else max(best, ang)
|
|
return best
|
|
|
|
|
|
def knee_flexion_deg(keypoints: list[Keypoint], min_conf: float = 0.5) -> float | None:
|
|
"""무릎 관절의 내부 각(엉덩이-무릎-발목)의 좌우 최소값. 180=곧게 섬, 작을수록 깊은
|
|
굴곡(웅크림). 신뢰 가능한 쪽 다리만 사용; 양쪽 다 불가하면 None."""
|
|
best = None
|
|
for h_i, k_i, a_i in ((L_HIP, L_KNEE, L_ANKLE), (R_HIP, R_KNEE, R_ANKLE)):
|
|
h, k, a = keypoints[h_i], keypoints[k_i], keypoints[a_i]
|
|
if h.conf < min_conf or k.conf < min_conf or a.conf < min_conf:
|
|
continue
|
|
v1 = (h.x - k.x, h.y - k.y)
|
|
v2 = (a.x - k.x, a.y - k.y)
|
|
n1 = math.hypot(*v1)
|
|
n2 = math.hypot(*v2)
|
|
if not n1 or not n2:
|
|
continue
|
|
cos = max(-1.0, min(1.0, (v1[0] * v2[0] + v1[1] * v2[1]) / (n1 * n2)))
|
|
ang = math.degrees(math.acos(cos))
|
|
best = ang if best is None else min(best, ang)
|
|
return best
|
|
|
|
|
|
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
|
|
|
|
|
|
# Keypoints below this confidence are treated as noise (commonly hallucinated at a frame
|
|
# corner). Averaging them into the motion estimate makes a still track read as moving, which
|
|
# suppresses no_motion/immobility exactly when they matter (a motionless fallen worker).
|
|
MOTION_MIN_CONF = 0.2
|
|
MOTION_MIN_KP = 3
|
|
|
|
|
|
def track_displacement(track) -> float:
|
|
"""Mean per-keypoint SPEED between the last two frames: displacement normalized by bbox
|
|
height AND by elapsed time (per second). Without the time term the value scales with the
|
|
frame rate, so the same physical stillness reads as 'moving' at low fps and 'still' at
|
|
high fps — flipping immobility/no_motion with the daemon's actual (load-dependent) fps.
|
|
Returns 1.0 (moving/unknown) with <2 frames or a non-positive dt.
|
|
|
|
Only keypoints the model is confident about in BOTH frames are averaged; below
|
|
MOTION_MIN_KP trustworthy points it falls back to the bbox centre (a stable proxy that
|
|
doesn't ride on one noisy joint).
|
|
|
|
NOTE: motion_thr in FallConfig/ImmobilityConfig is a per-SECOND threshold (units/second),
|
|
tunable via MOTION_THR in the environment; recalibrate on device against real jitter."""
|
|
if len(track.frames) < 2:
|
|
return 1.0
|
|
a, b = track.frames[-2], track.frames[-1]
|
|
dt = b.t - a.t
|
|
if dt <= 0:
|
|
return 1.0
|
|
h = b.bbox.height or 1.0
|
|
pairs = [(p, q) for p, q in zip(a.keypoints, b.keypoints)
|
|
if p.conf >= MOTION_MIN_CONF and q.conf >= MOTION_MIN_CONF]
|
|
if len(pairs) >= MOTION_MIN_KP:
|
|
d = sum(((p.x - q.x) ** 2 + (p.y - q.y) ** 2) ** 0.5 for p, q in pairs) / len(pairs)
|
|
else:
|
|
d = ((a.bbox.cx - b.bbox.cx) ** 2 + (a.bbox.cy - b.bbox.cy) ** 2) ** 0.5
|
|
return normalize(d, h) / dt
|