fix(edge): require sustained non-prone before FallDetector recover (FIX 2)
edge/detectors/fall.py update(): a single frame where prone was false while
state was in {FALLING, FALLEN, NO_MOTION} immediately emitted "recover" and
reset _State. One jittery frame (a momentary pose-estimation miss) could
wipe an in-progress CRITICAL no_motion incident.
Add FallConfig.recover_grace_s (default 1.0s) and _State.not_prone_since.
On the first non-prone frame while in an incident state, start the grace
timer instead of recovering immediately; only reset to STANDING once
recover_grace_s of sustained non-prone has elapsed. A re-prone frame while
in an incident state clears the pending grace timer (cancels the recover
attempt). The non-incident STANDING path is unchanged.
BEHAVIOR CHANGE: recover now requires recover_grace_s of sustained
non-prone instead of firing on a single frame.
Tests: test_recovery_clears_state (tests/test_fall.py) strengthened per
review brief to assert a single standing frame does NOT recover but
sustained standing across the grace window does. Added
test_single_noisy_nonprone_frame_does_not_cancel_no_motion covering the
life-safety scenario: one noisy non-prone frame during NO_MOTION must not
cancel the incident.
This commit is contained in:
parent
01c7f4636e
commit
3240428380
2 changed files with 39 additions and 5 deletions
|
|
@ -17,6 +17,7 @@ class FallConfig:
|
||||||
motion_thr: float = 0.02 # 정규화 관절변위 임계
|
motion_thr: float = 0.02 # 정규화 관절변위 임계
|
||||||
min_conf: float = 0.5
|
min_conf: float = 0.5
|
||||||
min_visible: int = 10
|
min_visible: int = 10
|
||||||
|
recover_grace_s: float = 1.0 # 사고 상태에서 회복 판정까지 요구되는 지속 비-prone 시간
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -27,6 +28,7 @@ class _State:
|
||||||
still_since: float | None = None
|
still_since: float | None = None
|
||||||
fall_emitted: bool = False
|
fall_emitted: bool = False
|
||||||
no_motion_emitted: bool = False
|
no_motion_emitted: bool = False
|
||||||
|
not_prone_since: float | None = None
|
||||||
|
|
||||||
|
|
||||||
class FallDetector:
|
class FallDetector:
|
||||||
|
|
@ -76,12 +78,20 @@ class FallDetector:
|
||||||
|
|
||||||
if not prone:
|
if not prone:
|
||||||
if st.name in ("FALLING", "FALLEN", "NO_MOTION"):
|
if st.name in ("FALLING", "FALLEN", "NO_MOTION"):
|
||||||
|
# 단일 튐(noisy) 프레임만으로 CRITICAL 사고를 즉시 취소하지 않도록
|
||||||
|
# recover_grace_s만큼 지속된 비-prone을 요구한다.
|
||||||
|
if st.not_prone_since is None:
|
||||||
|
st.not_prone_since = now
|
||||||
|
return None, {}
|
||||||
|
if now - st.not_prone_since >= self.cfg.recover_grace_s:
|
||||||
self._states[track.track_id] = _State(baseline_height=bbox.height)
|
self._states[track.track_id] = _State(baseline_height=bbox.height)
|
||||||
return "recover", {}
|
return "recover", {}
|
||||||
|
return None, {}
|
||||||
st.prone_since = None
|
st.prone_since = None
|
||||||
return None, {}
|
return None, {}
|
||||||
|
|
||||||
# prone 상태
|
# prone 상태 (재-prone 프레임은 대기 중인 recover 유예를 취소)
|
||||||
|
st.not_prone_since = None
|
||||||
if st.prone_since is None:
|
if st.prone_since is None:
|
||||||
st.prone_since = now
|
st.prone_since = now
|
||||||
st.name = "FALLING"
|
st.name = "FALLING"
|
||||||
|
|
|
||||||
|
|
@ -61,14 +61,38 @@ def test_no_motion_after_fall_escalates():
|
||||||
assert sig == "no_motion"
|
assert sig == "no_motion"
|
||||||
|
|
||||||
def test_recovery_clears_state():
|
def test_recovery_clears_state():
|
||||||
det = FallDetector(FallConfig(prone_confirm_s=0.5))
|
# Recovery requires SUSTAINED non-prone for recover_grace_s, not a single frame.
|
||||||
|
det = FallDetector(FallConfig(prone_confirm_s=0.5, recover_grace_s=1.0))
|
||||||
tr = _track()
|
tr = _track()
|
||||||
box = BBox(90, 290, 210, 320)
|
box = BBox(90, 290, 210, 320)
|
||||||
_feed(det, tr, _lying_kps(), box, t=0.0)
|
_feed(det, tr, _lying_kps(), box, t=0.0)
|
||||||
_feed(det, tr, _lying_kps(), box, t=0.6) # fall
|
_feed(det, tr, _lying_kps(), box, t=0.6) # fall
|
||||||
sig, _ = _feed(det, tr, _standing_kps(), BBox(40, 60, 60, 210), t=1.0)
|
standing_box = BBox(40, 60, 60, 210)
|
||||||
|
# a single standing frame right after a fall must NOT recover yet
|
||||||
|
sig, _ = _feed(det, tr, _standing_kps(), standing_box, t=1.0)
|
||||||
|
assert sig is None
|
||||||
|
# sustained standing through the grace window -> recover fires
|
||||||
|
sig, _ = _feed(det, tr, _standing_kps(), standing_box, t=2.5)
|
||||||
assert sig == "recover"
|
assert sig == "recover"
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_noisy_nonprone_frame_does_not_cancel_no_motion():
|
||||||
|
det = FallDetector(FallConfig(prone_confirm_s=0.5, no_motion_s=1.0, motion_thr=0.05,
|
||||||
|
recover_grace_s=1.0))
|
||||||
|
tr = _track()
|
||||||
|
box = BBox(90, 290, 210, 320)
|
||||||
|
_feed(det, tr, _lying_kps(), box, t=0.0)
|
||||||
|
_feed(det, tr, _lying_kps(), box, t=0.6) # -> fall
|
||||||
|
_feed(det, tr, _lying_kps(), box, t=1.0) # still
|
||||||
|
sig, _ = _feed(det, tr, _lying_kps(), box, t=2.0) # immobile >1.0s
|
||||||
|
assert sig == "no_motion"
|
||||||
|
# a single noisy non-prone frame within the grace window must NOT cancel
|
||||||
|
# the in-progress CRITICAL no_motion incident.
|
||||||
|
sig, _ = _feed(det, tr, _standing_kps(), BBox(40, 60, 60, 210), t=2.3)
|
||||||
|
assert sig is None
|
||||||
|
st = det._states[tr.track_id]
|
||||||
|
assert st.name == "NO_MOTION"
|
||||||
|
|
||||||
def test_low_confidence_is_ignored():
|
def test_low_confidence_is_ignored():
|
||||||
det = FallDetector(FallConfig())
|
det = FallDetector(FallConfig())
|
||||||
tr = _track()
|
tr = _track()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue