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).
This commit is contained in:
유창욱 2026-08-06 21:46:39 +09:00
parent 47ab1143e1
commit b358822247
14 changed files with 331 additions and 13 deletions

View file

@ -8,6 +8,7 @@ from edge.detectors.zone import ZoneIntrusionDetector
from edge.engine import EventEngine
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
from edge.models import BBox
from edge.events import EventType
def _head_boxes(persons: list) -> list:
@ -64,9 +65,13 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f
for tr in lost:
pool.forget(tr.track_id)
if events:
snaps = snapshotter.capture([frame], [_head_boxes(persons)])
# ERGO_REPORT is an anonymous aggregate (posture-exposure sums) — attaching a frame
# would turn a statistic into surveillance imagery. Capture only when a real incident
# needs evidence, and never attach snapshots to the ergo report itself.
evidential = [e for e in events if e.event_type != EventType.ERGO_REPORT]
snaps = snapshotter.capture([frame], [_head_boxes(persons)]) if evidential else []
for e in events:
sink.try_send(e, snaps)
sink.try_send(e, [] if e.event_type == EventType.ERGO_REPORT else snaps)
return events

View file

@ -0,0 +1,70 @@
from dataclasses import dataclass
from edge.tracking import Track
from edge.geometry import torso_angle_deg, arm_elevation_deg, knee_flexion_deg, core_visible
@dataclass
class ErgoConfig:
"""자세 부담(인체공학) 노출 집계. REBA의 밴드 개념을 2D 단안 관절에 정직하게 축소한
것 — 정밀 REBA 점수가 아니라 '고위험 자세에 얼마나 오래 노출됐나'를 잰다."""
report_interval_s: float = 3600.0 # 카메라당 리포트 주기(노출이 있었던 경우만)
min_conf: float = 0.5
trunk_mod_deg: float = 20.0 # REBA trunk 밴드: 20~60° 중위험
trunk_high_deg: float = 60.0 # 60°+ 고위험
arm_high_deg: float = 90.0 # 어깨 위 거상
knee_flex_deg: float = 110.0 # 이보다 작은 내부각 = 깊은 웅크림
max_frame_gap_s: float = 2.0 # 검출 공백은 노출로 세지 않는다 (dt 상한)
class ErgoMonitor:
"""트랙별 프레임 dt로 자세 밴드 노출 시간을 누적하고, 주기마다 카메라 단위의 익명
합계를 리포트로 내보낸다. 개인 식별 없음: 트랙·좌표는 합산 과정에서 소거된다.
사고 상태(FALLING/FALLEN/NO_MOTION)의 트랙은 엔진이 걸러 넘긴다 — 쓰러진 사람의
90° 몸통은 인체공학 노출이 아니라 사고다."""
def __init__(self, cfg: ErgoConfig):
self.cfg = cfg
self._period_start: float | None = None
self._acc = self._zero()
@staticmethod
def _zero() -> dict:
return {"observed_s": 0.0, "trunk_mod_s": 0.0, "trunk_high_s": 0.0,
"arm_high_s": 0.0, "squat_s": 0.0}
def update(self, track: Track, now: float) -> None:
if self._period_start is None:
self._period_start = now
if len(track.frames) < 2:
return
dt = track.latest.t - track.frames[-2].t
if dt <= 0:
return
dt = min(dt, self.cfg.max_frame_gap_s)
kps = track.latest.keypoints
# 몸통 각도는 어깨·엉덩이 4점이 신뢰될 때만 — 저신뢰 프레임의 각도는 노이즈다.
if not core_visible(kps, self.cfg.min_conf):
return
self._acc["observed_s"] += dt
trunk = torso_angle_deg(kps)
if trunk >= self.cfg.trunk_high_deg:
self._acc["trunk_high_s"] += dt
elif trunk >= self.cfg.trunk_mod_deg:
self._acc["trunk_mod_s"] += dt
arm = arm_elevation_deg(kps, self.cfg.min_conf)
if arm is not None and arm >= self.cfg.arm_high_deg:
self._acc["arm_high_s"] += dt
knee = knee_flexion_deg(kps, self.cfg.min_conf)
if knee is not None and knee < self.cfg.knee_flex_deg:
self._acc["squat_s"] += dt
def report(self, now: float) -> dict | None:
"""주기가 찼고 실제 노출 관측이 있었으면 합계를 내보내고 다음 주기를 시작한다."""
if self._period_start is None or now - self._period_start < self.cfg.report_interval_s:
return None
acc, self._acc = self._acc, self._zero()
self._period_start = now
if acc["observed_s"] <= 0:
return None # 빈 주기: 사람이 없던 시간의 리포트는 소음이다
return {k: round(v, 1) for k, v in acc.items()}

View file

@ -7,6 +7,7 @@ from edge.detectors.immobility import ImmobilityWatchdog
from edge.detectors.ghost import GhostIncidentDetector
from edge.detectors.zone import ZoneIntrusionDetector
from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig
from edge.detectors.ergonomics import ErgoMonitor, ErgoConfig
@dataclass
@ -23,13 +24,15 @@ _OCCUPANCY_TYPES = {"lone_worker": EventType.LONE_WORKER,
class EventEngine:
def __init__(self, fall: FallDetector, immobility: ImmobilityWatchdog,
ghost: GhostIncidentDetector, zone: ZoneIntrusionDetector,
cfg: EngineConfig, occupancy: OccupancyWatchdog | None = None):
cfg: EngineConfig, occupancy: OccupancyWatchdog | None = None,
ergonomics: ErgoMonitor | None = None):
self.fall = fall
self.immobility = immobility
self.ghost = ghost
self.zone = zone
# Keyword-with-default so the existing 5-arg constructors (runtime, tests) keep working.
self.occupancy = occupancy if occupancy is not None else OccupancyWatchdog(OccupancyConfig())
self.ergonomics = ergonomics if ergonomics is not None else ErgoMonitor(ErgoConfig())
self.cfg = cfg
self._last_emit: dict[tuple, float] = {}
@ -80,6 +83,10 @@ class EventEngine:
sig, meta = self.fall.update(tr, now)
state = self.fall._states.get(tr.track_id)
self.ghost.note_state(tr.track_id, state.name if state else "STANDING")
# 자세 부담 노출은 사고 상태가 아닌 트랙만 — 쓰러진 사람의 90° 몸통은
# 인체공학 노출이 아니라 이미 사고로 처리 중인 상태다.
if state is None or state.name == "STANDING":
self.ergonomics.update(tr, now)
if sig == "fall" and self._allow((EventType.FALL, tr.track_id), now):
events.append(self._mk(EventType.FALL, occurred_at, tr.track_id,
metrics=meta, spatial=foot))
@ -123,6 +130,12 @@ class EventEngine:
events.append(self._mk(etype, occurred_at, track_id=o["track_id"],
zone_id=o["zone_id"], metrics=o["metrics"]))
# 자세 부담 주기 리포트 — 간격이 자체 게이트라 쿨다운(_allow)을 타지 않는다.
# 카메라 단위 익명 합계: track_id·spatial 없음 (개인 좌표를 남기지 않는다).
ergo = self.ergonomics.report(now)
if ergo is not None:
events.append(self._mk(EventType.ERGO_REPORT, occurred_at, metrics=ergo))
for g in self.ghost.update(active_tracks, now):
# No _allow cooldown for GHOST: ghost.update pops each incident from its candidates as it
# emits, and track ids are never reused, so a given track fires GHOST at most once ever.

View file

@ -17,6 +17,7 @@ class EventType(str, Enum):
OVERCAPACITY = "overcapacity" # 존 정원 초과 지속
OVERSTAY = "overstay" # 존 체류 상한 초과 (트랙별)
NEAR_FALL = "near_fall" # 니어미스: FALLING까지 갔다가 확정 전 회복
ERGO_REPORT = "ergo_report" # 자세 부담 노출 주기 리포트 (카메라 단위, 익명)
class Severity(str, Enum):
@ -42,6 +43,7 @@ _SEVERITY = {
EventType.OVERSTAY: Severity.WARNING,
# 니어미스는 알람이 아니라 히트맵/통계 재료 — 소음이 되면 안 되므로 INFO.
EventType.NEAR_FALL: Severity.INFO,
EventType.ERGO_REPORT: Severity.INFO,
}

View file

@ -1,7 +1,8 @@
import math
from edge.models import (
Keypoint, BBox, NOSE, L_SHOULDER, R_SHOULDER, L_HIP, R_HIP, CORE_JOINTS,
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,
)
@ -45,6 +46,44 @@ def head_below_hips(keypoints: list[Keypoint], nose_thr: float = 0.2) -> bool:
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)

View file

@ -1,6 +1,7 @@
-- Heatmap raw material: normalized (0..1) spatial point per event, and the four new
-- event types (occupancy rules + near-miss). The chk_events_type constraint would REJECT
-- lone_worker/overcapacity/overstay/near_fall inserts on MySQL 8.0.16+ without this.
-- Heatmap raw material: normalized (0..1) spatial point per event, and the five new
-- event types (occupancy rules + near-miss + ergo report). The chk_events_type constraint
-- would REJECT the new types' inserts on MySQL 8.0.16+ without this. (Never applied to any
-- deployment before this revision, so the type list is edited in place.)
ALTER TABLE move_events
ADD COLUMN spatial_x DOUBLE NULL AFTER metrics_json,
ADD COLUMN spatial_y DOUBLE NULL AFTER spatial_x;
@ -9,5 +10,5 @@ ALTER TABLE move_events DROP CHECK chk_events_type;
ALTER TABLE move_events ADD CONSTRAINT chk_events_type CHECK (event_type IN (
'fall','no_motion','immobility','ghost','zone_warning',
'zone_restricted','pinch','monitoring_degraded','device_fault',
'lone_worker','overcapacity','overstay','near_fall'
'lone_worker','overcapacity','overstay','near_fall','ergo_report'
));

View file

@ -22,7 +22,7 @@
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
near_fall: '넘어질 뻔함 (니어미스)'
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트'
};
var SEVERITY_LABELS = {

View file

@ -28,9 +28,29 @@
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
near_fall: '넘어질 뻔함 (니어미스)'
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트'
};
// Metric keys -> Korean, so the detail view can print what the edge measured (the ergo
// report's whole value IS its metrics). Unknown keys fall through as the raw key.
var METRIC_LABELS = {
observed_s: '관측 시간', trunk_high_s: '허리 고위험(60°+) 노출',
trunk_mod_s: '허리 중위험(20~60°) 노출', arm_high_s: '팔 거상(90°+) 노출',
squat_s: '웅크림 노출', prone_s: '엎드림 지속', immobility_s: '부동 지속',
waited_s: '재출현 대기', dwell_s: '체류 시간', limit_s: '체류 상한',
alone_s: '단독 작업 지속', workers: '감지 인원', required: '최소 인원', limit: '정원',
torso_angle: '몸통 각도(°)', aspect_ratio: '박스 종횡비', height_remaining: '잔여 높이 비율',
last_state: '소실 시 상태'
};
function metricValue(key, v) {
if (/_s$/.test(key) && typeof v === 'number') {
if (v >= 90) return Math.round(v / 60) + '분 ' + Math.round(v % 60) + '초';
return v + '초';
}
return String(v);
}
var meta = document.getElementById('event-detail-meta');
var snapBox = document.getElementById('event-detail-snapshots');
if (!meta) return;
@ -100,6 +120,12 @@
addRow('확인', textNode(ev.acknowledged_by || '미확인'));
if (ev.false_positive_by) addRow('오탐', textNode(ev.false_positive_by + ' 태그'));
// 측정 지표: 엣지가 이벤트에 실은 수치 전부. ergo_report는 이 블록이 본문이다.
var metrics = ev.metrics || {};
Object.keys(metrics).forEach(function (key) {
addRow(METRIC_LABELS[key] || key, textNode(metricValue(key, metrics[key])));
});
if (!snapBox) return;
snapBox.textContent = '';
var snaps = data.snapshots || [];

View file

@ -146,7 +146,7 @@ CREATE TABLE move_events (
CONSTRAINT chk_events_type CHECK (event_type IN (
'fall','no_motion','immobility','ghost','zone_warning',
'zone_restricted','pinch','monitoring_degraded','device_fault',
'lone_worker','overcapacity','overstay','near_fall'
'lone_worker','overcapacity','overstay','near_fall','ergo_report'
)),
CONSTRAINT chk_events_severity CHECK (severity IN ('critical','high','warning','info'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View file

@ -6,7 +6,7 @@ final class EventValidator
{
private const TYPES = ['fall','no_motion','immobility','ghost','zone_warning',
'zone_restricted','pinch','monitoring_degraded','device_fault',
'lone_worker','overcapacity','overstay','near_fall'];
'lone_worker','overcapacity','overstay','near_fall','ergo_report'];
private const SEVERITIES = ['critical','high','warning','info'];
private const REQUIRED = ['device_id','camera_id','event_type','severity','occurred_at'];

View file

@ -49,7 +49,7 @@ final class EventValidatorTest extends TestCase
$this->assertFalse(EventValidator::validate($p)['ok']);
}
public function testAcceptsOccupancyRuleEventTypes(): void {
foreach (['lone_worker', 'overcapacity', 'overstay', 'near_fall'] as $type) {
foreach (['lone_worker', 'overcapacity', 'overstay', 'near_fall', 'ergo_report'] as $type) {
$p = $this->valid(); $p['event_type'] = $type;
$this->assertTrue(EventValidator::validate($p)['ok'], "$type must be a known type");
}

View file

@ -120,6 +120,37 @@ def test_dropout_then_redetection_still_confirms_fall():
assert any(e.event_type == EventType.FALL for e in sink.events)
def test_ergo_report_carries_no_snapshots():
# ERGO_REPORT는 익명 통계다 — 프레임이 붙는 순간 통계가 감시 이미지가 된다.
from edge.detectors.ergonomics import ErgoMonitor, ErgoConfig
from edge.detectors.fall import FallDetector, FallConfig
class _SnapSink:
def __init__(self): self.sent = []
def try_send(self, event, snapshots):
self.sent.append((event, snapshots)); return True
src = _Source()
pose = _ScriptedPose([[_person_at(500)], [_person_at(500)]])
tracker = IouTracker()
pool = TrackSmootherPool()
engine = EventEngine(
FallDetector(FallConfig(prone_confirm_s=999)),
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
ZoneIntrusionDetector([], ZoneConfig()),
EngineConfig(camera_id="cam-01"),
ergonomics=ErgoMonitor(ErgoConfig(report_interval_s=0.5)))
ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _SnapSink()
times = iter([0.0, 0.6])
def now_fn(): return next(times)
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
ergo = [(e, s) for e, s in sink.sent if e.event_type == EventType.ERGO_REPORT]
assert len(ergo) == 1
assert ergo[0][1] == [] # 스냅샷 미부착
def _lying_person_at(x1, x2):
kps = [Keypoint((x1 + x2) / 2, 15, 0.9) for _ in range(17)]
return Person(BBox(x1, 0, x2, 20), kps)

View file

@ -261,6 +261,43 @@ def test_ghost_spatial_is_normalized_with_frame_size():
assert 0.0 <= x <= 1.0 and 0.0 <= y <= 1.0 # not raw pixels (150, 305)
def test_ergo_report_emitted_anonymous_and_info():
from edge.detectors.ergonomics import ErgoMonitor, ErgoConfig
eng = EventEngine(
FallDetector(FallConfig(prone_confirm_s=999)),
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
ZoneIntrusionDetector([], ZoneConfig()),
EngineConfig(camera_id="cam-01"),
ergonomics=ErgoMonitor(ErgoConfig(report_interval_s=0.5)))
tr = Track(1, deque(maxlen=90))
_standing_track(tr, 0.0); eng.process([tr], [], now=0.0)
_standing_track(tr, 0.6)
events = eng.process([tr], [], now=0.6)
rep = [e for e in events if e.event_type == EventType.ERGO_REPORT]
assert len(rep) == 1
assert rep[0].severity == Severity.INFO
assert rep[0].track_id is None and rep[0].spatial is None # 익명 합계: 개인 좌표 없음
assert rep[0].metrics["observed_s"] == 0.6
def test_incident_state_track_does_not_accrue_ergo_exposure():
# 쓰러진 사람의 90° 몸통은 인체공학 노출이 아니라 사고다 — FALLING 이후는 집계 제외.
from edge.detectors.ergonomics import ErgoMonitor, ErgoConfig
eng = EventEngine(
FallDetector(FallConfig(prone_confirm_s=999, falling_confirm_s=0.05)),
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
ZoneIntrusionDetector([], ZoneConfig()),
EngineConfig(camera_id="cam-01"),
ergonomics=ErgoMonitor(ErgoConfig(report_interval_s=0.2)))
tr = Track(1, deque(maxlen=90))
for t in (0.0, 0.1, 0.2, 0.3):
_lying_track(tr, t)
events = eng.process([tr], [], now=t)
assert not any(e.event_type == EventType.ERGO_REPORT for e in events)
def test_allow_cooldown_primitive():
eng = _engine() # EngineConfig(camera_id="cam-01", cooldown_s=30.0)
key = ("FALL", 1)

94
tests/test_ergonomics.py Normal file
View file

@ -0,0 +1,94 @@
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