fix(edge): close audit P2-6/7/8 - id swaps, one-frame FALLING, stale-track timers
- P2-6 tracking: global greedy IoU matching (all pairs, best overlap first) replaces track-iteration-order greedy, so a crossing worker's box can no longer be stolen by whichever track came first. Any track in a contended match (person overlapping 2 tracks, or track overlapping 2 persons) is reported via tracker.ambiguous(); run_once resets its detector + smoother state so FALLEN/baseline/immobility never ride an id swap onto the wrong person. A genuinely fallen worker re-confirms within prone_confirm_s. - P2-7 fall: STANDING -> FALLING now requires sustained prone (falling_confirm_s, 1.0s). One prone-looking frame (a bend, a bbox glitch) no longer arms the ghost detector's CRITICAL if the person then leaves. FALLEN / no_motion timing is unchanged (keyed off prone_since). - P2-8 app: run_once feeds the engine and smoothers only tracks re-detected this tick; a track coasting inside max_age with a frozen frame is no-data, so prone-confirm / no-motion / immobility timers cannot accrue on stale data. Re-detection still confirms the fall (dropout pauses evidence). TDD: 7 new tests verified failing on the pre-fix code (git stash), then 203 passed / 2 skipped (hardware) with the fixes.
This commit is contained in:
parent
eaff1765a4
commit
32f2237db7
7 changed files with 184 additions and 17 deletions
|
|
@ -95,13 +95,23 @@ misconfig/degradation fail LOUD.
|
|||
normalize by elapsed time.
|
||||
DONE — track_displacement divides by dt (per-second speed); test_geometry.py locks
|
||||
FPS-invariance. NOTE: motion_thr is now per-second and needs on-device re-tuning.
|
||||
- [ ] **P2-6 [M]** `edge/tracking.py:50` — greedy IoU matching swaps IDs when workers cross,
|
||||
- [x] **P2-6 [M]** `edge/tracking.py:50` — greedy IoU matching swaps IDs when workers cross,
|
||||
transferring FALLEN state + frozen baseline to the wrong person. **Fix:** gate matches;
|
||||
reset per-track safety state on a low-confidence match.
|
||||
- [ ] **P2-7 [M]** `edge/detectors/fall.py:92` — a single prone-looking frame sets FALLING and
|
||||
DONE — global greedy (all pairs sorted by IoU desc) replaces track-order greedy; any track
|
||||
in a contended match (person↔2 tracks or track↔2 persons) is reported via
|
||||
`tracker.ambiguous()` and run_once resets its engine + smoother state. A still-prone worker
|
||||
re-confirms within prone_confirm_s. test_tracking.py + test_app.py lock both.
|
||||
- [x] **P2-7 [M]** `edge/detectors/fall.py:92` — a single prone-looking frame sets FALLING and
|
||||
arms a CRITICAL ghost if the person then leaves. **Fix:** require sustained prone.
|
||||
- [ ] **P2-8 [M]** `edge/app.py:30` — detection dropouts keep a track 'active' with a frozen
|
||||
DONE — STANDING→FALLING now requires sustained prone (`falling_confirm_s`, default 1.0s,
|
||||
dataclass-only, no env knob). FALLEN/no_motion timing unchanged (keyed off prone_since).
|
||||
test_fall.py locks it.
|
||||
- [x] **P2-8 [M]** `edge/app.py:30` — detection dropouts keep a track 'active' with a frozen
|
||||
frame; time-based timers accrue on stale data. **Fix:** treat a stale track as no-data.
|
||||
DONE — run_once feeds the engine (and smoothers) only tracks re-detected THIS tick
|
||||
(`latest.t == now`); a stale track is no-data until re-detected or lost. Re-detection still
|
||||
confirms the fall (dropout pauses evidence, doesn't erase it). test_app.py locks both.
|
||||
- [x] **P2-9 [M]** `edge/detectors/zone.py:45` — foot point averages both ankles with no
|
||||
confidence check; a hallucinated ankle at (0,0) drags the foot into/out of a zone. **Fix:**
|
||||
confidence-gate the ankles, fall back to bbox-bottom-center.
|
||||
|
|
|
|||
15
edge/app.py
15
edge/app.py
|
|
@ -41,7 +41,18 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f
|
|||
persons = pose.infer(frame)
|
||||
tracks = tracker.update(persons, now) # RAW keypoints; smoothing happens per-track below
|
||||
lost = tracker.lost()
|
||||
for tr in tracks:
|
||||
# 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)
|
||||
|
|
@ -49,7 +60,7 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f
|
|||
# `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(tracks, lost, now, frame_wh=(w, h), wall_now=wall_now)
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ class FallConfig:
|
|||
aspect_ratio: float = 1.25
|
||||
height_collapse: float = 0.35 # baseline 대비 남은 높이 비율(이 값 미만이면 급붕괴)
|
||||
prone_confirm_s: float = 3.0
|
||||
falling_confirm_s: float = 1.0 # STANDING→FALLING에 요구되는 지속 prone (P2-7)
|
||||
no_motion_s: float = 10.0
|
||||
motion_thr: float = 0.25 # per-second normalized joint speed (units/s); MOTION_THR env
|
||||
min_conf: float = 0.5
|
||||
|
|
@ -104,6 +105,9 @@ class FallDetector:
|
|||
st.not_prone_since = None
|
||||
if st.prone_since is None:
|
||||
st.prone_since = now
|
||||
# FALLING은 위험 상태다: 이 상태로 트랙이 사라지면 ghost가 CRITICAL을 무장한다.
|
||||
# 단 한 프레임의 prone(굽힘·bbox 튐)으로 들어가지 않도록 지속 prone을 요구한다.
|
||||
if st.name == "STANDING" and now - st.prone_since >= self.cfg.falling_confirm_s:
|
||||
st.name = "FALLING"
|
||||
if now - st.prone_since >= self.cfg.prone_confirm_s and not st.fall_emitted:
|
||||
st.name = "FALLEN"
|
||||
|
|
|
|||
|
|
@ -40,24 +40,46 @@ class IouTracker:
|
|||
self._tracks: dict[int, Track] = {}
|
||||
self._last_seen: dict[int, float] = {}
|
||||
self._lost: list[Track] = []
|
||||
self._ambiguous: set[int] = set()
|
||||
self._next_id = 1
|
||||
|
||||
def update(self, persons: list[Person], t: float) -> list[Track]:
|
||||
self._lost = []
|
||||
unmatched = list(persons)
|
||||
# 기존 트랙과 그리디 IoU 매칭
|
||||
for tid, track in list(self._tracks.items()):
|
||||
best, best_iou = None, self.iou_thr
|
||||
for p in unmatched:
|
||||
self._ambiguous = set()
|
||||
# 전역 그리디: 모든 (트랙, 사람) 쌍을 IoU 내림차순으로 배정. 트랙 순회 순서 우선의
|
||||
# 로컬 그리디는 교차 시 먼저 온 트랙이 남의 박스를 가로채(0.48이 0.74를 이김) FALLEN
|
||||
# 상태·frozen baseline이 다른 사람에게 넘어갔다.
|
||||
pairs = []
|
||||
track_cands: dict[int, int] = {}
|
||||
person_cands: dict[int, int] = {}
|
||||
for tid, track in self._tracks.items():
|
||||
for i, p in enumerate(persons):
|
||||
iou = _iou(track.latest.bbox, p.bbox)
|
||||
if iou >= best_iou:
|
||||
best, best_iou = p, iou
|
||||
if best is not None:
|
||||
unmatched.remove(best)
|
||||
track.frames.append(TrackFrame(t, best.bbox, best.keypoints))
|
||||
if iou >= self.iou_thr:
|
||||
pairs.append((iou, tid, i))
|
||||
track_cands[tid] = track_cands.get(tid, 0) + 1
|
||||
person_cands[i] = person_cands.get(i, 0) + 1
|
||||
pairs.sort(key=lambda pr: pr[0], reverse=True)
|
||||
used_tracks: set[int] = set()
|
||||
used_persons: set[int] = set()
|
||||
for _iou_v, tid, i in pairs:
|
||||
if tid in used_tracks or i in used_persons:
|
||||
continue
|
||||
used_tracks.add(tid)
|
||||
used_persons.add(i)
|
||||
p = persons[i]
|
||||
self._tracks[tid].frames.append(TrackFrame(t, p.bbox, p.keypoints))
|
||||
self._last_seen[tid] = t
|
||||
# 경합 = 저신뢰 매칭. 한 사람이 두 트랙과, 또는 한 트랙이 두 사람과 임계 이상 겹치면
|
||||
# (교차) 어느 배정이든 스왑일 수 있다 — 연루된 모든 트랙을 표시해 호출자가 트랙별
|
||||
# 안전 상태(FALLEN·baseline·부동 타이머)를 리셋하게 한다.
|
||||
for _iou_v, tid, i in pairs:
|
||||
if track_cands[tid] > 1 or person_cands[i] > 1:
|
||||
self._ambiguous.add(tid)
|
||||
# 신규 트랙
|
||||
for p in unmatched:
|
||||
for i, p in enumerate(persons):
|
||||
if i in used_persons:
|
||||
continue
|
||||
tid = self._next_id
|
||||
self._next_id += 1
|
||||
self._tracks[tid] = Track(tid, deque([TrackFrame(t, p.bbox, p.keypoints)],
|
||||
|
|
@ -72,3 +94,7 @@ class IouTracker:
|
|||
|
||||
def lost(self) -> list[Track]:
|
||||
return list(self._lost)
|
||||
|
||||
def ambiguous(self) -> set[int]:
|
||||
"""직전 update에서 겹침 경합에 연루된(매칭이 스왑일 수 있는) 트랙 id."""
|
||||
return set(self._ambiguous)
|
||||
|
|
|
|||
|
|
@ -72,6 +72,79 @@ def test_run_once_stamps_events_with_wall_clock_not_monotonic():
|
|||
assert falls and falls[0].occurred_at == 1_700_000_000.6
|
||||
|
||||
|
||||
def _engine(**fall_kw):
|
||||
return EventEngine(
|
||||
FallDetector(FallConfig(**fall_kw)),
|
||||
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
|
||||
GhostIncidentDetector(GhostConfig()),
|
||||
ZoneIntrusionDetector([], ZoneConfig()),
|
||||
EngineConfig(camera_id="cam-01"),
|
||||
)
|
||||
|
||||
|
||||
class _ScriptedPose:
|
||||
def __init__(self, frames): self.frames = list(frames)
|
||||
def infer(self, frame): return self.frames.pop(0)
|
||||
|
||||
|
||||
def test_dropout_tick_does_not_feed_detectors_stale_frame():
|
||||
# A track not re-detected this tick still sits in the tracker (< max_age) with a frozen
|
||||
# latest frame. Passing it to the engine lets prone_confirm accrue on stale data: here the
|
||||
# dropout tick alone would confirm the fall. A stale track must be treated as no-data.
|
||||
src = _Source()
|
||||
pose = _ScriptedPose([[_lying_person()], []])
|
||||
tracker = IouTracker() # max_age=1.0 > dropout gap, so the track stays active
|
||||
pool = TrackSmootherPool()
|
||||
engine = _engine(prone_confirm_s=0.5, falling_confirm_s=0.1)
|
||||
ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _Sink()
|
||||
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) # dropout tick
|
||||
assert not any(e.event_type == EventType.FALL for e in sink.events)
|
||||
|
||||
|
||||
def test_dropout_then_redetection_still_confirms_fall():
|
||||
# ...but the person re-detected still prone MUST confirm — the dropout pauses evidence,
|
||||
# it does not erase the incident.
|
||||
src = _Source()
|
||||
pose = _ScriptedPose([[_lying_person()], [], [_lying_person()]])
|
||||
tracker = IouTracker()
|
||||
pool = TrackSmootherPool()
|
||||
engine = _engine(prone_confirm_s=0.5, falling_confirm_s=0.1)
|
||||
ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _Sink()
|
||||
times = iter([0.0, 0.3, 0.7])
|
||||
def now_fn(): return next(times)
|
||||
for _ in range(3):
|
||||
run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
|
||||
assert any(e.event_type == EventType.FALL for e in sink.events)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def test_contended_crossing_resets_fall_state():
|
||||
# Two prone tracks; then one box overlapping BOTH (a crossing). The surviving match may
|
||||
# be a swap, so the contended track's fall state must reset — no FALL may fire off the
|
||||
# pre-crossing prone timer.
|
||||
src = _Source()
|
||||
pose = _ScriptedPose([
|
||||
[_lying_person_at(0, 40), _lying_person_at(30, 70)],
|
||||
[_lying_person_at(15, 55)],
|
||||
])
|
||||
tracker = IouTracker(iou_thr=0.3)
|
||||
pool = TrackSmootherPool()
|
||||
engine = _engine(prone_confirm_s=0.05, falling_confirm_s=0.01)
|
||||
ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _Sink()
|
||||
times = iter([0.0, 0.1])
|
||||
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)
|
||||
assert not any(e.event_type == EventType.FALL for e in sink.events)
|
||||
|
||||
|
||||
def _person_at(cx):
|
||||
# standing person centered at cx; nose at (cx, 60)
|
||||
kps = [Keypoint(cx, 150, 0.9) for _ in range(17)]
|
||||
|
|
|
|||
|
|
@ -119,6 +119,19 @@ def test_single_noisy_nonprone_frame_does_not_cancel_no_motion():
|
|||
st = det._states[tr.track_id]
|
||||
assert st.name == "NO_MOTION"
|
||||
|
||||
def test_single_prone_frame_does_not_enter_falling():
|
||||
# FALLING is a DANGEROUS state: if the track vanishes while in it, the ghost detector
|
||||
# arms a CRITICAL incident. A single prone-looking frame (a bend, a bbox glitch) must
|
||||
# not be enough — FALLING requires sustained prone (falling_confirm_s).
|
||||
det = FallDetector(FallConfig()) # falling_confirm_s=1.0
|
||||
tr = _track()
|
||||
box = BBox(90, 290, 210, 320)
|
||||
_feed(det, tr, _lying_kps(), box, t=0.0)
|
||||
assert det._states[1].name == "STANDING"
|
||||
_feed(det, tr, _lying_kps(), box, t=1.2) # sustained prone -> now FALLING
|
||||
assert det._states[1].name == "FALLING"
|
||||
|
||||
|
||||
def test_low_confidence_is_ignored():
|
||||
det = FallDetector(FallConfig())
|
||||
tr = _track()
|
||||
|
|
|
|||
|
|
@ -36,3 +36,33 @@ def test_history_accumulates_frames():
|
|||
tracks = tr.update([_person(0, 0, 10, 20)], t=0.1)
|
||||
assert len(tracks[0].frames) == 2
|
||||
assert tracks[0].latest.t == 0.1
|
||||
|
||||
|
||||
def test_crossing_keeps_ids_via_global_best_match():
|
||||
# Track-iteration-order greedy let track 1 steal the box that overlaps track 2 far more
|
||||
# (0.48 vs 0.74), leaving track 2 unmatched and its person re-created as a NEW id —
|
||||
# FALLEN state and frozen baseline would ride along to the wrong person. Global greedy
|
||||
# must assign the highest-IoU pair first.
|
||||
tr = IouTracker(iou_thr=0.3)
|
||||
tr.update([_person(0, 0, 10, 20), _person(5, 0, 15, 20)], t=0.0)
|
||||
tracks = tr.update([_person(3.5, 0, 13.5, 20), _person(-4, 0, 6, 20)], t=0.1)
|
||||
by_id = {t.track_id: t for t in tracks}
|
||||
assert set(by_id) == {1, 2} # no phantom new track
|
||||
assert by_id[2].latest.bbox.x1 == 3.5
|
||||
assert by_id[1].latest.bbox.x1 == -4
|
||||
|
||||
|
||||
def test_contended_match_is_flagged_ambiguous():
|
||||
# One person overlapping BOTH tracks (a crossing): whichever track wins, the match may
|
||||
# be a swap, so every contended track must be reported for a safety-state reset.
|
||||
tr = IouTracker(iou_thr=0.3)
|
||||
tr.update([_person(0, 0, 10, 20), _person(5, 0, 15, 20)], t=0.0)
|
||||
tr.update([_person(2, 0, 12, 20)], t=0.1)
|
||||
assert tr.ambiguous() == {1, 2}
|
||||
|
||||
|
||||
def test_clean_match_is_not_ambiguous():
|
||||
tr = IouTracker(iou_thr=0.3)
|
||||
tr.update([_person(0, 0, 10, 20), _person(100, 0, 110, 20)], t=0.0)
|
||||
tr.update([_person(1, 1, 11, 21), _person(101, 1, 111, 21)], t=0.1)
|
||||
assert tr.ambiguous() == set()
|
||||
|
|
|
|||
Loading…
Reference in a new issue