feat(v1): relax fall sensitivity for low overhead cameras
Adds configurable event.require_rapid_drop (default off), require_lower_body (default off) and horizontal_angle_threshold_degrees (default 45). With the lenient defaults a sustained horizontal pose alone enters SUSPECT and confirms after the confirm window, which is now the main false-positive guard; missing knees/ankles no longer reject the pose. Thresholds flow into config_version. End-to-end smoke confirms a lying pose; 70 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,10 @@
|
||||
"suspect_window_seconds": 0.5,
|
||||
"confirm_window_seconds": 1.8,
|
||||
"recovery_window_seconds": 2.0,
|
||||
"cooldown_seconds": 10.0
|
||||
"cooldown_seconds": 10.0,
|
||||
"require_rapid_drop": false,
|
||||
"require_lower_body": false,
|
||||
"horizontal_angle_threshold_degrees": 45.0
|
||||
},
|
||||
"artifacts": {
|
||||
"event_dir": "../artifacts/events"
|
||||
|
||||
@@ -69,6 +69,9 @@ class EventConfig:
|
||||
confirm_window_seconds: float
|
||||
recovery_window_seconds: float
|
||||
cooldown_seconds: float
|
||||
require_rapid_drop: bool = False
|
||||
require_lower_body: bool = False
|
||||
horizontal_angle_threshold_degrees: float = 45.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -105,6 +108,9 @@ class AppConfig:
|
||||
"confirm_window_seconds": self.event.confirm_window_seconds,
|
||||
"recovery_window_seconds": self.event.recovery_window_seconds,
|
||||
"cooldown_seconds": self.event.cooldown_seconds,
|
||||
"require_rapid_drop": self.event.require_rapid_drop,
|
||||
"require_lower_body": self.event.require_lower_body,
|
||||
"horizontal_angle_threshold_degrees": self.event.horizontal_angle_threshold_degrees,
|
||||
},
|
||||
}
|
||||
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
@@ -139,6 +145,12 @@ def _number(value: Any, field_name: str, minimum: float, maximum: float) -> floa
|
||||
return result
|
||||
|
||||
|
||||
def _bool(value: Any, field_name: str) -> bool:
|
||||
if not isinstance(value, bool):
|
||||
raise ConfigError("{0} must be a boolean".format(field_name))
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_path(config_path: Path, value: Any, field_name: str) -> Path:
|
||||
raw_path = Path(_text(value, field_name))
|
||||
if raw_path.is_absolute():
|
||||
@@ -274,6 +286,14 @@ def load_config(path: Path) -> AppConfig:
|
||||
0.0,
|
||||
3600.0,
|
||||
),
|
||||
require_rapid_drop=_bool(event_raw.get("require_rapid_drop", False), "event.require_rapid_drop"),
|
||||
require_lower_body=_bool(event_raw.get("require_lower_body", False), "event.require_lower_body"),
|
||||
horizontal_angle_threshold_degrees=_number(
|
||||
event_raw.get("horizontal_angle_threshold_degrees", 45.0),
|
||||
"event.horizontal_angle_threshold_degrees",
|
||||
0.0,
|
||||
90.0,
|
||||
),
|
||||
)
|
||||
|
||||
artifacts = _mapping(root.get("artifacts"), "artifacts")
|
||||
|
||||
+6
-3
@@ -7,7 +7,9 @@ from typing import Optional
|
||||
from v1.pose import PersonPose
|
||||
|
||||
|
||||
_REQUIRED_JOINTS = (5, 6, 11, 12, 13, 14, 15, 16)
|
||||
_TORSO_JOINTS = (5, 6, 11, 12) # shoulders and hips (used by the geometry)
|
||||
_LOWER_JOINTS = (13, 14, 15, 16) # knees and ankles (often occluded in a fall)
|
||||
_REQUIRED_JOINTS = _TORSO_JOINTS + _LOWER_JOINTS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -29,7 +31,7 @@ class PoseEvidence:
|
||||
|
||||
|
||||
def assess_pose_quality(
|
||||
pose: Optional[PersonPose], threshold: float
|
||||
pose: Optional[PersonPose], threshold: float, require_lower_body: bool = True
|
||||
) -> PoseQuality:
|
||||
if not 0.0 <= threshold <= 1.0:
|
||||
raise ValueError("threshold must be between 0 and 1")
|
||||
@@ -40,7 +42,8 @@ def assess_pose_quality(
|
||||
visible_count = sum(
|
||||
point.confidence >= threshold for point in pose.keypoints
|
||||
)
|
||||
if any(pose.keypoints[index].confidence < threshold for index in _REQUIRED_JOINTS):
|
||||
required = _REQUIRED_JOINTS if require_lower_body else _TORSO_JOINTS
|
||||
if any(pose.keypoints[index].confidence < threshold for index in required):
|
||||
return PoseQuality(False, "required_joint_low_confidence", visible_count)
|
||||
return PoseQuality(True, "accepted", visible_count)
|
||||
|
||||
|
||||
+11
-3
@@ -9,8 +9,11 @@ from v1.fall_state import Evidence, FallState
|
||||
class FallEvidencePolicy:
|
||||
"""Require a recent rapid drop before a horizontal pose becomes a candidate."""
|
||||
|
||||
def __init__(self, suspect_window_seconds: float) -> None:
|
||||
def __init__(
|
||||
self, suspect_window_seconds: float, require_rapid_drop: bool = True
|
||||
) -> None:
|
||||
self._suspect_window_seconds = float(suspect_window_seconds)
|
||||
self._require_rapid_drop = bool(require_rapid_drop)
|
||||
self._rapid_drop_at: Dict[str, float] = {}
|
||||
|
||||
def evaluate(
|
||||
@@ -30,8 +33,13 @@ class FallEvidencePolicy:
|
||||
candidate = False
|
||||
if state is FallState.SUSPECT:
|
||||
candidate = pose_evidence.horizontal_pose
|
||||
elif pose_evidence.horizontal_pose and track_id in self._rapid_drop_at:
|
||||
candidate = timestamp - self._rapid_drop_at[track_id] <= self._suspect_window_seconds
|
||||
elif pose_evidence.horizontal_pose:
|
||||
if not self._require_rapid_drop:
|
||||
candidate = True
|
||||
elif track_id in self._rapid_drop_at:
|
||||
candidate = (
|
||||
timestamp - self._rapid_drop_at[track_id] <= self._suspect_window_seconds
|
||||
)
|
||||
|
||||
recovery = (
|
||||
state in (FallState.CONFIRMED, FallState.RECOVERING)
|
||||
|
||||
+14
-2
@@ -49,6 +49,8 @@ class FallPipeline:
|
||||
policy: FallEvidencePolicy,
|
||||
state_machine: FallStateMachine,
|
||||
keypoint_confidence_threshold: float,
|
||||
require_lower_body: bool = True,
|
||||
horizontal_angle_threshold_degrees: float = 35.0,
|
||||
) -> None:
|
||||
if not 0.0 <= keypoint_confidence_threshold <= 1.0:
|
||||
raise ValueError("keypoint_confidence_threshold must be between 0 and 1")
|
||||
@@ -57,6 +59,8 @@ class FallPipeline:
|
||||
self._policy = policy
|
||||
self._state_machine = state_machine
|
||||
self._keypoint_confidence_threshold = float(keypoint_confidence_threshold)
|
||||
self._require_lower_body = bool(require_lower_body)
|
||||
self._horizontal_angle_threshold_degrees = float(horizontal_angle_threshold_degrees)
|
||||
self._previous_evidence: Dict[str, PoseEvidence] = {}
|
||||
self._active_track_ids = set()
|
||||
|
||||
@@ -74,7 +78,10 @@ class FallPipeline:
|
||||
return cls(
|
||||
pose_adapter=pose_adapter,
|
||||
tracker=PersonTracker(),
|
||||
policy=FallEvidencePolicy(config.event.suspect_window_seconds),
|
||||
policy=FallEvidencePolicy(
|
||||
config.event.suspect_window_seconds,
|
||||
require_rapid_drop=config.event.require_rapid_drop,
|
||||
),
|
||||
state_machine=FallStateMachine(
|
||||
confirm_window_seconds=config.event.confirm_window_seconds,
|
||||
recovery_window_seconds=config.event.recovery_window_seconds,
|
||||
@@ -83,6 +90,8 @@ class FallPipeline:
|
||||
session_id=session_id or new_session_id(),
|
||||
),
|
||||
keypoint_confidence_threshold=config.event.keypoint_confidence_threshold,
|
||||
require_lower_body=config.event.require_lower_body,
|
||||
horizontal_angle_threshold_degrees=config.event.horizontal_angle_threshold_degrees,
|
||||
)
|
||||
|
||||
def process(self, packet: FramePacket) -> FrameAnalysis:
|
||||
@@ -102,12 +111,15 @@ class FallPipeline:
|
||||
people = []
|
||||
for tracked in tracked_poses:
|
||||
quality = assess_pose_quality(
|
||||
tracked.pose, threshold=self._keypoint_confidence_threshold
|
||||
tracked.pose,
|
||||
threshold=self._keypoint_confidence_threshold,
|
||||
require_lower_body=self._require_lower_body,
|
||||
)
|
||||
pose_evidence = extract_evidence(
|
||||
tracked.pose,
|
||||
quality=quality,
|
||||
previous=self._previous_evidence.get(tracked.track_id),
|
||||
horizontal_angle_threshold_degrees=self._horizontal_angle_threshold_degrees,
|
||||
)
|
||||
state_before = self._state_machine.state_of(tracked.track_id)
|
||||
evidence = self._policy.evaluate(
|
||||
|
||||
@@ -140,6 +140,20 @@ def test_public_example_config_has_no_embedded_credentials():
|
||||
assert "rtsp_url_env" in source
|
||||
|
||||
|
||||
def test_sensitivity_fields_default_to_lenient(tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
_write_config(
|
||||
config_file,
|
||||
{"id": "c", "host": "192.0.2.10", "username": "u", "password": "p"},
|
||||
)
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
assert config.event.require_rapid_drop is False
|
||||
assert config.event.require_lower_body is False
|
||||
assert config.event.horizontal_angle_threshold_degrees == 45.0
|
||||
|
||||
|
||||
def test_build_ffmpeg_options_variants():
|
||||
assert build_ffmpeg_options() == "rtsp_transport;tcp|stimeout;5000000|fflags;nobuffer|flags;low_delay"
|
||||
assert build_ffmpeg_options("udp", 3, False) == "rtsp_transport;udp|stimeout;3000000"
|
||||
|
||||
@@ -36,6 +36,14 @@ def test_missing_ankles_rejects_pose():
|
||||
assert quality.reason == "required_joint_low_confidence"
|
||||
|
||||
|
||||
def test_missing_ankles_accepted_when_lower_body_not_required():
|
||||
quality = assess_pose_quality(
|
||||
_pose(missing_ankles=True), threshold=0.4, require_lower_body=False
|
||||
)
|
||||
|
||||
assert quality.accepted is True
|
||||
|
||||
|
||||
def test_horizontal_body_is_evidence_not_event():
|
||||
pose = _pose(horizontal=True)
|
||||
quality = assess_pose_quality(pose, threshold=0.4)
|
||||
|
||||
@@ -42,6 +42,20 @@ def test_rejected_pose_clears_pending_drop_before_the_next_horizontal_pose():
|
||||
assert candidate.is_fall_candidate is False
|
||||
|
||||
|
||||
def test_horizontal_pose_alone_starts_candidate_when_rapid_drop_not_required():
|
||||
policy = FallEvidencePolicy(suspect_window_seconds=0.5, require_rapid_drop=False)
|
||||
|
||||
candidate = policy.evaluate(
|
||||
"P-0001", _evidence(horizontal=True), now=0.0, state=FallState.NORMAL
|
||||
)
|
||||
upright = policy.evaluate(
|
||||
"P-0002", _evidence(horizontal=False), now=0.0, state=FallState.NORMAL
|
||||
)
|
||||
|
||||
assert candidate.is_fall_candidate is True
|
||||
assert upright.is_fall_candidate is False
|
||||
|
||||
|
||||
def test_upright_pose_is_recovery_evidence_only_after_confirmation():
|
||||
policy = FallEvidencePolicy(suspect_window_seconds=0.5)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user