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).
94 lines
3.7 KiB
Python
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
|