move_detect/edge/events.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

87 lines
3.3 KiB
Python

import uuid as _uuid
from dataclasses import dataclass, field
from enum import Enum
class EventType(str, Enum):
FALL = "fall"
NO_MOTION = "no_motion"
IMMOBILITY = "immobility"
GHOST = "ghost"
ZONE_WARNING = "zone_warning"
ZONE_RESTRICTED = "zone_restricted"
PINCH = "pinch"
MONITORING_DEGRADED = "monitoring_degraded"
DEVICE_FAULT = "device_fault"
LONE_WORKER = "lone_worker" # 2인 1조 존에 단독 작업 지속
OVERCAPACITY = "overcapacity" # 존 정원 초과 지속
OVERSTAY = "overstay" # 존 체류 상한 초과 (트랙별)
NEAR_FALL = "near_fall" # 니어미스: FALLING까지 갔다가 확정 전 회복
ERGO_REPORT = "ergo_report" # 자세 부담 노출 주기 리포트 (카메라 단위, 익명)
class Severity(str, Enum):
CRITICAL = "critical"
HIGH = "high"
WARNING = "warning"
INFO = "info"
_SEVERITY = {
EventType.NO_MOTION: Severity.CRITICAL,
EventType.GHOST: Severity.CRITICAL,
EventType.FALL: Severity.HIGH,
EventType.ZONE_RESTRICTED: Severity.HIGH,
EventType.PINCH: Severity.HIGH,
EventType.IMMOBILITY: Severity.WARNING,
EventType.ZONE_WARNING: Severity.WARNING,
EventType.MONITORING_DEGRADED: Severity.WARNING,
EventType.DEVICE_FAULT: Severity.WARNING,
# 단독 작업은 규정 위반이자 사고 시 구조 지연 요인 — 정원/체류보다 한 단계 높게.
EventType.LONE_WORKER: Severity.HIGH,
EventType.OVERCAPACITY: Severity.WARNING,
EventType.OVERSTAY: Severity.WARNING,
# 니어미스는 알람이 아니라 히트맵/통계 재료 — 소음이 되면 안 되므로 INFO.
EventType.NEAR_FALL: Severity.INFO,
EventType.ERGO_REPORT: Severity.INFO,
}
def severity_for(event_type: EventType) -> Severity:
return _SEVERITY.get(event_type, Severity.INFO)
@dataclass(frozen=True)
class Event:
event_type: EventType
severity: Severity
camera_id: str
track_id: int | None
zone_id: str | None
occurred_at: float
duration_s: float
metrics: dict
spatial: tuple | None # (x, y) 유령사고 등 공간 기준
# Stable idempotency key: generated ONCE at event creation and carried unchanged through the
# offline queue and every retry, so a POST that succeeded server-side but timed out
# client-side is deduplicated by the server (see server IngestController) instead of stored twice.
event_uuid: str = field(default_factory=lambda: _uuid.uuid4().hex)
def to_payload(self, device_id: str) -> dict:
payload = {
"device_id": device_id,
"event_uuid": self.event_uuid,
"camera_id": self.camera_id,
"event_type": self.event_type.value,
"severity": self.severity.value,
"track_id": self.track_id,
"zone_id": self.zone_id,
"occurred_at": self.occurred_at,
"duration_s": round(self.duration_s, 2),
"metrics": self.metrics,
}
# 정규화 발/기준 좌표(0..1) — 서버 히트맵의 원료. None이면 키 자체를 생략해
# 구서버(스키마에 spatial 없음)와의 하위호환을 지킨다.
if self.spatial is not None:
payload["spatial"] = [round(float(self.spatial[0]), 4), round(float(self.spatial[1]), 4)]
return payload