move_detect/tests/test_ergonomics.py
유창욱 b358822247 feat(ergo): posture-load exposure scoring (REBA bands) with anonymous reports
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).
2026-08-06 21:46:39 +09:00

94 lines
3.7 KiB
Python

from collections import deque
from edge.models import Keypoint, BBox
from edge.tracking import Track, TrackFrame
from edge.geometry import arm_elevation_deg, knee_flexion_deg
from edge.detectors.ergonomics import ErgoMonitor, ErgoConfig
def _kps(overrides=None, conf=0.9):
"""직립 자세 기본형: 몸통 수직, 팔 내림, 다리 곧게. overrides로 관절만 바꾼다."""
base = {
0: (50, 40), # nose
5: (40, 100), 6: (60, 100), # shoulders
7: (40, 140), 8: (60, 140), # elbows (아래로 = 팔 내림)
11: (40, 200), 12: (60, 200), # hips
13: (40, 260), 14: (60, 260), # knees
15: (40, 320), 16: (60, 320), # ankles
}
if overrides:
base.update(overrides)
kps = []
for i in range(17):
x, y = base.get(i, (50, 150))
kps.append(Keypoint(x, y, conf))
return kps
def _feed(mon, track, kps, t):
track.frames.append(TrackFrame(t, BBox(20, 30, 80, 330), kps))
mon.update(track, now=t)
def test_arm_elevation_down_vs_raised():
assert arm_elevation_deg(_kps()) < 20.0 # 팔 내림
raised = _kps({7: (40, 60), 8: (60, 60)}) # 팔꿈치가 어깨 위
assert arm_elevation_deg(raised) > 90.0
low_conf = [Keypoint(0, 0, 0.1) for _ in range(17)]
assert arm_elevation_deg(low_conf) is None # 측정 불가 ≠ 0°
def test_knee_flexion_straight_vs_squat():
assert knee_flexion_deg(_kps()) > 160.0 # 곧게 섬
squat = _kps({13: (70, 220), 14: (90, 220), 15: (40, 230), 16: (60, 230)})
assert knee_flexion_deg(squat) < 110.0
def test_upright_posture_accrues_no_risk_exposure():
mon = ErgoMonitor(ErgoConfig(report_interval_s=10.0))
tr = Track(1, deque(maxlen=90))
for i in range(11):
_feed(mon, tr, _kps(), t=float(i))
rep = mon.report(now=11.0)
assert rep is not None
assert rep["observed_s"] == 10.0
assert rep["trunk_high_s"] == 0.0 and rep["trunk_mod_s"] == 0.0
assert rep["arm_high_s"] == 0.0 and rep["squat_s"] == 0.0
def test_bent_trunk_accrues_high_band():
mon = ErgoMonitor(ErgoConfig(report_interval_s=10.0))
tr = Track(1, deque(maxlen=90))
# 어깨가 엉덩이 높이 근처까지 숙임: 몸통이 수평에 가깝다 (>60°)
bent = _kps({5: (140, 190), 6: (160, 190)})
for i in range(11):
_feed(mon, tr, bent, t=float(i))
rep = mon.report(now=11.0)
assert rep["trunk_high_s"] == 10.0
def test_detection_gap_does_not_count_as_exposure():
mon = ErgoMonitor(ErgoConfig(report_interval_s=100.0, max_frame_gap_s=2.0))
tr = Track(1, deque(maxlen=90))
_feed(mon, tr, _kps(), t=0.0)
_feed(mon, tr, _kps(), t=60.0) # 60초 공백 — 2초 상한으로만 집계
mon._period_start = -100.0 # 주기 강제 만료
rep = mon.report(now=61.0)
assert rep["observed_s"] == 2.0
def test_empty_period_reports_nothing():
mon = ErgoMonitor(ErgoConfig(report_interval_s=5.0))
tr = Track(1, deque(maxlen=90))
_feed(mon, tr, [Keypoint(0, 0, 0.1) for _ in range(17)], t=0.0) # 저신뢰만
_feed(mon, tr, [Keypoint(0, 0, 0.1) for _ in range(17)], t=1.0)
assert mon.report(now=6.0) is None # 관측 0 → 리포트 없음
assert mon.report(now=7.0) is None # 주기는 리셋됨
def test_low_confidence_core_is_not_measured():
mon = ErgoMonitor(ErgoConfig(report_interval_s=5.0))
tr = Track(1, deque(maxlen=90))
_feed(mon, tr, _kps(conf=0.2), t=0.0)
_feed(mon, tr, _kps(conf=0.2), t=1.0)
assert mon._acc["observed_s"] == 0.0