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

93 lines
4.3 KiB
Python

from edge.config import Config
from edge.tracking import IouTracker
from edge.smoothing import TrackSmootherPool
from edge.detectors.fall import FallDetector
from edge.detectors.immobility import ImmobilityWatchdog
from edge.detectors.ghost import GhostIncidentDetector
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:
# Head region for face anonymization (PIPA). Sized from the PERSON bbox, not a fixed
# ~40x40 patch — a close worker fills far more than 40px, so a constant box left the face
# fully identifiable. Centered on the nose when it is confident, else on the bbox's top
# (the head is the upper part of the person) so a bad keypoint can't move the blur off it.
boxes = []
for p in persons:
bb = p.bbox
nose = p.keypoints[0]
half_w = max(8.0, 0.25 * bb.width)
if nose.conf >= 0.3:
cx, cy = nose.x, nose.y
else:
cx, cy = bb.cx, bb.y1 + 0.12 * bb.height
boxes.append(BBox(cx - half_w, cy - 0.18 * bb.height,
cx + half_w, cy + 0.08 * bb.height))
return boxes
def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn, wall_fn=None):
ok, frame = source.read()
if not ok:
# No frame this tick. Return None (not []) so the daemon can tell "camera produced no
# frame" apart from "frame processed, no events" — a dead/unplugged USB camera returns
# (False, None) forever, and counting it as a healthy frame made a monitoring outage
# look perfectly green (fps~=target, cameras_erroring=0).
return None
now = now_fn()
persons = pose.infer(frame)
tracks = tracker.update(persons, now) # RAW keypoints; smoothing happens per-track below
lost = tracker.lost()
# A contended (crossing) match may be an id swap — FALLEN state, frozen baseline, or an
# immobility timer would ride along to the wrong person. Reset that track's safety state;
# a genuinely fallen worker still prone re-confirms within prone_confirm_s.
for tid in tracker.ambiguous():
engine.forget(tid)
pool.forget(tid)
# A track not re-detected this tick still sits in the tracker (< max_age) with a frozen
# latest frame. Feeding it downstream would re-smooth the same frame and let time-based
# detector timers (prone confirm / no-motion / immobility) accrue on stale data — treat
# it as no-data until it is re-detected or ages into lost().
fresh = [tr for tr in tracks if tr.latest.t == now]
for tr in fresh:
f = tr.latest
f.keypoints = pool.smoother_for(tr.track_id).smooth(f.keypoints, now)
ring.push(now, frame)
h, w = frame.shape[:2]
# `now` (monotonic) drives detector timers; wall time stamps the event (occurred_at). Off the
# daemon (direct callers / tests) wall_fn is None → engine falls back to now, as before.
wall_now = wall_fn() if wall_fn is not None else None
events = engine.process(fresh, lost, now, frame_wh=(w, h), wall_now=wall_now)
for tr in lost:
pool.forget(tr.track_id)
if events:
# 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, [] if e.event_type == EventType.ERGO_REPORT else snaps)
return events
def build_pipeline(config: Config, source, pose) -> dict:
return {
"source": source,
"pose": pose,
"tracker": IouTracker(),
"pool": TrackSmootherPool(),
"engine": EventEngine(
FallDetector(config.fall),
ImmobilityWatchdog(config.immobility),
GhostIncidentDetector(config.ghost),
ZoneIntrusionDetector([], config.zone),
config.engine,
),
"ring": RingBuffer(),
"snapshotter": Snapshotter(FaceBlurrer()),
}