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.engine import EventEngine
from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter
from edge.models import BBox from edge.models import BBox
from edge.events import EventType
def _head_boxes(persons: list) -> list: 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: for tr in lost:
pool.forget(tr.track_id) pool.forget(tr.track_id)
if events: 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: for e in events:
sink.try_send(e, snaps) sink.try_send(e, [] if e.event_type == EventType.ERGO_REPORT else snaps)
return events 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.ghost import GhostIncidentDetector
from edge.detectors.zone import ZoneIntrusionDetector from edge.detectors.zone import ZoneIntrusionDetector
from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig
from edge.detectors.ergonomics import ErgoMonitor, ErgoConfig
@dataclass @dataclass
@ -23,13 +24,15 @@ _OCCUPANCY_TYPES = {"lone_worker": EventType.LONE_WORKER,
class EventEngine: class EventEngine:
def __init__(self, fall: FallDetector, immobility: ImmobilityWatchdog, def __init__(self, fall: FallDetector, immobility: ImmobilityWatchdog,
ghost: GhostIncidentDetector, zone: ZoneIntrusionDetector, ghost: GhostIncidentDetector, zone: ZoneIntrusionDetector,
cfg: EngineConfig, occupancy: OccupancyWatchdog | None = None): cfg: EngineConfig, occupancy: OccupancyWatchdog | None = None,
ergonomics: ErgoMonitor | None = None):
self.fall = fall self.fall = fall
self.immobility = immobility self.immobility = immobility
self.ghost = ghost self.ghost = ghost
self.zone = zone self.zone = zone
# Keyword-with-default so the existing 5-arg constructors (runtime, tests) keep working. # 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.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.cfg = cfg
self._last_emit: dict[tuple, float] = {} self._last_emit: dict[tuple, float] = {}
@ -80,6 +83,10 @@ class EventEngine:
sig, meta = self.fall.update(tr, now) sig, meta = self.fall.update(tr, now)
state = self.fall._states.get(tr.track_id) state = self.fall._states.get(tr.track_id)
self.ghost.note_state(tr.track_id, state.name if state else "STANDING") 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): if sig == "fall" and self._allow((EventType.FALL, tr.track_id), now):
events.append(self._mk(EventType.FALL, occurred_at, tr.track_id, events.append(self._mk(EventType.FALL, occurred_at, tr.track_id,
metrics=meta, spatial=foot)) metrics=meta, spatial=foot))
@ -123,6 +130,12 @@ class EventEngine:
events.append(self._mk(etype, occurred_at, track_id=o["track_id"], events.append(self._mk(etype, occurred_at, track_id=o["track_id"],
zone_id=o["zone_id"], metrics=o["metrics"])) 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): for g in self.ghost.update(active_tracks, now):
# No _allow cooldown for GHOST: ghost.update pops each incident from its candidates as it # 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. # 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" # 존 정원 초과 지속 OVERCAPACITY = "overcapacity" # 존 정원 초과 지속
OVERSTAY = "overstay" # 존 체류 상한 초과 (트랙별) OVERSTAY = "overstay" # 존 체류 상한 초과 (트랙별)
NEAR_FALL = "near_fall" # 니어미스: FALLING까지 갔다가 확정 전 회복 NEAR_FALL = "near_fall" # 니어미스: FALLING까지 갔다가 확정 전 회복
ERGO_REPORT = "ergo_report" # 자세 부담 노출 주기 리포트 (카메라 단위, 익명)
class Severity(str, Enum): class Severity(str, Enum):
@ -42,6 +43,7 @@ _SEVERITY = {
EventType.OVERSTAY: Severity.WARNING, EventType.OVERSTAY: Severity.WARNING,
# 니어미스는 알람이 아니라 히트맵/통계 재료 — 소음이 되면 안 되므로 INFO. # 니어미스는 알람이 아니라 히트맵/통계 재료 — 소음이 되면 안 되므로 INFO.
EventType.NEAR_FALL: Severity.INFO, EventType.NEAR_FALL: Severity.INFO,
EventType.ERGO_REPORT: Severity.INFO,
} }

View file

@ -1,7 +1,8 @@
import math import math
from edge.models import ( 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 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: def visible_count(keypoints: list[Keypoint], thr: float = 0.2) -> int:
return sum(1 for k in keypoints if k.conf >= thr) 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 -- Heatmap raw material: normalized (0..1) spatial point per event, and the five new
-- event types (occupancy rules + near-miss). The chk_events_type constraint would REJECT -- event types (occupancy rules + near-miss + ergo report). The chk_events_type constraint
-- lone_worker/overcapacity/overstay/near_fall inserts on MySQL 8.0.16+ without this. -- 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 ALTER TABLE move_events
ADD COLUMN spatial_x DOUBLE NULL AFTER metrics_json, ADD COLUMN spatial_x DOUBLE NULL AFTER metrics_json,
ADD COLUMN spatial_y DOUBLE NULL AFTER spatial_x; 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 ( ALTER TABLE move_events ADD CONSTRAINT chk_events_type CHECK (event_type IN (
'fall','no_motion','immobility','ghost','zone_warning', 'fall','no_motion','immobility','ghost','zone_warning',
'zone_restricted','pinch','monitoring_degraded','device_fault', '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: '제한구역 침입', ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상', pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과', lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
near_fall: '넘어질 뻔함 (니어미스)' near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트'
}; };
var SEVERITY_LABELS = { var SEVERITY_LABELS = {

View file

@ -28,9 +28,29 @@
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입', ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상', pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과', 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 meta = document.getElementById('event-detail-meta');
var snapBox = document.getElementById('event-detail-snapshots'); var snapBox = document.getElementById('event-detail-snapshots');
if (!meta) return; if (!meta) return;
@ -100,6 +120,12 @@
addRow('확인', textNode(ev.acknowledged_by || '미확인')); addRow('확인', textNode(ev.acknowledged_by || '미확인'));
if (ev.false_positive_by) addRow('오탐', textNode(ev.false_positive_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; if (!snapBox) return;
snapBox.textContent = ''; snapBox.textContent = '';
var snaps = data.snapshots || []; var snaps = data.snapshots || [];

View file

@ -146,7 +146,7 @@ CREATE TABLE move_events (
CONSTRAINT chk_events_type CHECK (event_type IN ( CONSTRAINT chk_events_type CHECK (event_type IN (
'fall','no_motion','immobility','ghost','zone_warning', 'fall','no_motion','immobility','ghost','zone_warning',
'zone_restricted','pinch','monitoring_degraded','device_fault', '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')) CONSTRAINT chk_events_severity CHECK (severity IN ('critical','high','warning','info'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View file

@ -6,7 +6,7 @@ final class EventValidator
{ {
private const TYPES = ['fall','no_motion','immobility','ghost','zone_warning', private const TYPES = ['fall','no_motion','immobility','ghost','zone_warning',
'zone_restricted','pinch','monitoring_degraded','device_fault', '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 SEVERITIES = ['critical','high','warning','info'];
private const REQUIRED = ['device_id','camera_id','event_type','severity','occurred_at']; 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']); $this->assertFalse(EventValidator::validate($p)['ok']);
} }
public function testAcceptsOccupancyRuleEventTypes(): void { 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; $p = $this->valid(); $p['event_type'] = $type;
$this->assertTrue(EventValidator::validate($p)['ok'], "$type must be a known 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) 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): def _lying_person_at(x1, x2):
kps = [Keypoint((x1 + x2) / 2, 15, 0.9) for _ in range(17)] kps = [Keypoint((x1 + x2) / 2, 15, 0.9) for _ in range(17)]
return Person(BBox(x1, 0, x2, 20), kps) 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) 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(): def test_allow_cooldown_primitive():
eng = _engine() # EngineConfig(camera_id="cam-01", cooldown_s=30.0) eng = _engine() # EngineConfig(camera_id="cam-01", cooldown_s=30.0)
key = ("FALL", 1) 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