新增:垃圾检测算法 GarbageDetectionAlgorithm v1.0
Edge 端实现: - algorithms.py 新增 GarbageDetectionAlgorithm 类 状态机:IDLE → CONFIRMING_GARBAGE → ALARMED → CONFIRMING_CLEAR → IDLE 默认参数:confirm_garbage_sec=60, confirm_clear_sec=60, cooldown_sec=1800 target_classes=['garbage'], alarm_level=2(普通) 与 IllegalParking 同构但去掉 PARKED_COUNTDOWN 阶段 - AlgorithmManager 6 处集成: _PARAM_TYPES、default_params、load_bind_from_redis(热更新)、 update_algorithm_params、register_algorithm、get_algorithm_status 测试:test_garbage_algorithm.py 覆盖 8 个场景,全部通过 - 无垃圾保持 IDLE - 持续 60s 有垃圾 → 触发告警 - 冷却期内不重复触发 - 清理后发 resolve → IDLE - 清理期内垃圾再出现 → 回 ALARMED - reset() 清空状态 - 多目标计数 - 非 target_class 忽略 WVP 后端/前端改动方案预留在 docs/garbage_algorithm_backend_frontend_plan.md (后续 ROI 绑定时再实施,本次只改 Edge 端)
This commit is contained in:
303
algorithms.py
303
algorithms.py
@@ -1550,6 +1550,247 @@ class NonMotorVehicleParkingAlgorithm(BaseAlgorithm):
|
||||
return state_info
|
||||
|
||||
|
||||
class GarbageDetectionAlgorithm(BaseAlgorithm):
|
||||
"""
|
||||
垃圾检测算法(状态机版本 v1.0)
|
||||
|
||||
状态机:
|
||||
IDLE → CONFIRMING_GARBAGE → ALARMED → CONFIRMING_CLEAR → IDLE
|
||||
|
||||
业务流程:
|
||||
1. 检测到垃圾 → 垃圾确认期(confirm_garbage_sec,默认60秒,ratio>=0.6)
|
||||
2. 确认有垃圾 → 触发告警(ALARMED 状态)
|
||||
3. 垃圾消失(ratio<0.15)→ 消失确认期(confirm_clear_sec,默认60秒)
|
||||
4. 消失确认期内持续 ratio<0.2 → 发送 resolve 事件 → 回到 IDLE
|
||||
|
||||
与 IllegalParking 的差异:无 PARKED_COUNTDOWN 阶段(垃圾无"临时停留"概念)。
|
||||
使用滑动窗口(10秒)抗抖动,只检测 garbage 类。
|
||||
"""
|
||||
|
||||
# 状态定义
|
||||
STATE_IDLE = "IDLE"
|
||||
STATE_CONFIRMING_GARBAGE = "CONFIRMING_GARBAGE"
|
||||
STATE_ALARMED = "ALARMED"
|
||||
STATE_CONFIRMING_CLEAR = "CONFIRMING_CLEAR"
|
||||
|
||||
# 告警级别常量(默认值,可通过 params 覆盖)
|
||||
DEFAULT_ALARM_LEVEL = 2 # 普通
|
||||
|
||||
# 滑动窗口参数
|
||||
WINDOW_SIZE_SEC = 10
|
||||
|
||||
# 阈值常量
|
||||
RATIO_CONFIRMING_DROP = 0.3 # 确认期内命中率低于此值则回到 IDLE
|
||||
RATIO_CONFIRM_GARBAGE = 0.6 # 确认有垃圾的命中率阈值
|
||||
RATIO_ALARMED_CLEAR = 0.15 # 已告警状态下进入消失确认的阈值
|
||||
RATIO_CLEAR_RETURN = 0.5 # 消失确认期间垃圾再次出现的阈值
|
||||
RATIO_CLEAR_CONFIRM = 0.2 # 消失确认完成的阈值
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
confirm_garbage_sec: int = 60,
|
||||
confirm_clear_sec: int = 60,
|
||||
cooldown_sec: int = 1800,
|
||||
target_classes: Optional[List[str]] = None,
|
||||
alarm_level: Optional[int] = None,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
self.confirm_garbage_sec = confirm_garbage_sec
|
||||
self.confirm_clear_sec = confirm_clear_sec
|
||||
self.cooldown_sec = cooldown_sec
|
||||
self.target_classes = target_classes or ["garbage"]
|
||||
self._alarm_level = alarm_level if alarm_level is not None else self.DEFAULT_ALARM_LEVEL
|
||||
|
||||
# 状态变量
|
||||
self.state: str = self.STATE_IDLE
|
||||
self.state_start_time: Optional[datetime] = None
|
||||
|
||||
# 滑动窗口:存储 (timestamp, has_garbage: bool)
|
||||
self._detection_window: deque = deque(maxlen=1000)
|
||||
|
||||
# 告警追踪
|
||||
self._garbage_start_time: Optional[datetime] = None
|
||||
|
||||
# 冷却期管理
|
||||
self.alert_cooldowns: Dict[str, datetime] = {}
|
||||
|
||||
def _check_target_classes(self, detection: Dict) -> bool:
|
||||
"""检查检测目标是否属于垃圾类别"""
|
||||
det_class = detection.get("class", "")
|
||||
return det_class in self.target_classes
|
||||
|
||||
def _update_window(self, current_time: datetime, has_garbage: bool):
|
||||
"""更新滑动窗口"""
|
||||
self._detection_window.append((current_time, has_garbage))
|
||||
cutoff = current_time - timedelta(seconds=self.WINDOW_SIZE_SEC)
|
||||
while self._detection_window and self._detection_window[0][0] < cutoff:
|
||||
self._detection_window.popleft()
|
||||
|
||||
def _get_window_ratio(self) -> float:
|
||||
"""获取滑动窗口内的检测命中率"""
|
||||
if not self._detection_window:
|
||||
return 0.0
|
||||
hits = sum(1 for _, has in self._detection_window if has)
|
||||
return hits / len(self._detection_window)
|
||||
|
||||
def _scan_tracks(self, tracks: List[Dict], roi_id: str) -> Tuple[bool, int, List[float], float]:
|
||||
"""
|
||||
一次遍历 tracks,返回 (has_target, count, latest_bbox, max_confidence)。
|
||||
过滤 target_classes。
|
||||
"""
|
||||
has_target = False
|
||||
count = 0
|
||||
latest_bbox: List[float] = []
|
||||
max_confidence = 0.0
|
||||
for det in tracks:
|
||||
if self._check_detection_in_roi(det, roi_id) and self._check_target_classes(det):
|
||||
has_target = True
|
||||
count += 1
|
||||
if not latest_bbox:
|
||||
latest_bbox = det.get("bbox", [])
|
||||
conf = det.get("confidence", 0.0)
|
||||
if conf > max_confidence:
|
||||
max_confidence = conf
|
||||
return has_target, count, latest_bbox, max_confidence
|
||||
|
||||
def process(
|
||||
self,
|
||||
roi_id: str,
|
||||
camera_id: str,
|
||||
tracks: List[Dict],
|
||||
current_time: Optional[datetime] = None,
|
||||
) -> List[Dict]:
|
||||
"""处理单帧检测结果"""
|
||||
current_time = current_time or datetime.now()
|
||||
alerts = []
|
||||
|
||||
# 一次遍历获取所有信息
|
||||
roi_has_garbage, garbage_count, scan_bbox, scan_confidence = self._scan_tracks(tracks, roi_id)
|
||||
|
||||
# 更新滑动窗口
|
||||
self._update_window(current_time, roi_has_garbage)
|
||||
|
||||
# 计算一次比率,后续分支复用
|
||||
ratio = self._get_window_ratio()
|
||||
|
||||
# === 状态机处理 ===
|
||||
|
||||
if self.state == self.STATE_IDLE:
|
||||
if roi_has_garbage:
|
||||
self.state = self.STATE_CONFIRMING_GARBAGE
|
||||
self.state_start_time = current_time
|
||||
logger.debug(f"ROI {roi_id}: IDLE → CONFIRMING_GARBAGE")
|
||||
|
||||
elif self.state == self.STATE_CONFIRMING_GARBAGE:
|
||||
if self.state_start_time is None:
|
||||
self.state = self.STATE_IDLE
|
||||
return alerts
|
||||
|
||||
elapsed = (current_time - self.state_start_time).total_seconds()
|
||||
|
||||
if ratio < self.RATIO_CONFIRMING_DROP:
|
||||
# 命中率过低,可能只是闪现
|
||||
self.state = self.STATE_IDLE
|
||||
self.state_start_time = None
|
||||
logger.debug(
|
||||
f"ROI {roi_id}: CONFIRMING_GARBAGE → IDLE "
|
||||
f"(ratio={ratio:.2f}<{self.RATIO_CONFIRMING_DROP})"
|
||||
)
|
||||
elif elapsed >= self.confirm_garbage_sec and ratio >= self.RATIO_CONFIRM_GARBAGE:
|
||||
# 确认有垃圾持续存在,检查冷却期
|
||||
cooldown_key = f"{camera_id}_{roi_id}"
|
||||
if cooldown_key not in self.alert_cooldowns or \
|
||||
(current_time - self.alert_cooldowns[cooldown_key]).total_seconds() > self.cooldown_sec:
|
||||
|
||||
self._garbage_start_time = self.state_start_time
|
||||
alerts.append({
|
||||
"roi_id": roi_id,
|
||||
"camera_id": camera_id,
|
||||
"bbox": scan_bbox,
|
||||
"alert_type": "garbage",
|
||||
"alarm_level": self._alarm_level,
|
||||
"confidence": scan_confidence,
|
||||
"message": f"检测到垃圾(持续{int(elapsed)}秒,{garbage_count}处)",
|
||||
"first_frame_time": self._garbage_start_time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
"garbage_count": garbage_count,
|
||||
})
|
||||
|
||||
self.alert_cooldowns[cooldown_key] = current_time
|
||||
self.state = self.STATE_ALARMED
|
||||
logger.warning(f"ROI {roi_id}: CONFIRMING_GARBAGE → ALARMED (垃圾告警触发)")
|
||||
else:
|
||||
self.state = self.STATE_IDLE
|
||||
self.state_start_time = None
|
||||
logger.debug(f"ROI {roi_id}: CONFIRMING_GARBAGE → IDLE (冷却期内)")
|
||||
|
||||
elif self.state == self.STATE_ALARMED:
|
||||
if ratio < self.RATIO_ALARMED_CLEAR:
|
||||
self.state = self.STATE_CONFIRMING_CLEAR
|
||||
self.state_start_time = current_time
|
||||
logger.debug(
|
||||
f"ROI {roi_id}: ALARMED → CONFIRMING_CLEAR "
|
||||
f"(ratio={ratio:.2f}<{self.RATIO_ALARMED_CLEAR})"
|
||||
)
|
||||
|
||||
elif self.state == self.STATE_CONFIRMING_CLEAR:
|
||||
if self.state_start_time is None:
|
||||
self.state = self.STATE_IDLE
|
||||
return alerts
|
||||
|
||||
elapsed = (current_time - self.state_start_time).total_seconds()
|
||||
|
||||
if ratio >= self.RATIO_CLEAR_RETURN:
|
||||
# 垃圾又出现(或清扫者挡住片刻),回到 ALARMED
|
||||
self.state = self.STATE_ALARMED
|
||||
self.state_start_time = None
|
||||
logger.debug(f"ROI {roi_id}: CONFIRMING_CLEAR → ALARMED (垃圾仍在)")
|
||||
elif elapsed >= self.confirm_clear_sec and ratio < self.RATIO_CLEAR_CONFIRM:
|
||||
# 确认垃圾已被清理
|
||||
if self._last_alarm_id and self._garbage_start_time:
|
||||
duration_ms = int((current_time - self._garbage_start_time).total_seconds() * 1000)
|
||||
alerts.append({
|
||||
"alert_type": "alarm_resolve",
|
||||
"resolve_alarm_id": self._last_alarm_id,
|
||||
"duration_ms": duration_ms,
|
||||
"last_frame_time": current_time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
"resolve_type": "garbage_removed",
|
||||
})
|
||||
logger.info(f"ROI {roi_id}: 垃圾告警已解决(垃圾被清理)")
|
||||
|
||||
self.state = self.STATE_IDLE
|
||||
self.state_start_time = None
|
||||
self._last_alarm_id = None
|
||||
self._garbage_start_time = None
|
||||
self.alert_cooldowns.clear() # 清理后清空冷却,新垃圾可正常告警
|
||||
logger.debug(f"ROI {roi_id}: CONFIRMING_CLEAR → IDLE")
|
||||
|
||||
return alerts
|
||||
|
||||
def reset(self):
|
||||
"""重置算法状态"""
|
||||
self.state = self.STATE_IDLE
|
||||
self.state_start_time = None
|
||||
self._last_alarm_id = None
|
||||
self._garbage_start_time = None
|
||||
self._detection_window.clear()
|
||||
self.alert_cooldowns.clear()
|
||||
|
||||
def get_state(self, current_time: Optional[datetime] = None) -> Dict[str, Any]:
|
||||
"""获取当前状态"""
|
||||
current_time = current_time or datetime.now()
|
||||
window_ratio = self._get_window_ratio()
|
||||
state_info = {
|
||||
"state": self.state,
|
||||
"state_start_time": self.state_start_time.isoformat() if self.state_start_time else None,
|
||||
"window_ratio": window_ratio,
|
||||
}
|
||||
if self.state in (self.STATE_ALARMED,) and self._garbage_start_time:
|
||||
state_info["garbage_duration_sec"] = (current_time - self._garbage_start_time).total_seconds()
|
||||
state_info["alarm_id"] = self._last_alarm_id
|
||||
return state_info
|
||||
|
||||
|
||||
class AlgorithmManager:
|
||||
# 参数类型定义,用于三级合并后的类型强制转换
|
||||
_PARAM_TYPES = {
|
||||
@@ -1573,6 +1814,10 @@ class AlgorithmManager:
|
||||
"confirm_vehicle_sec": int, "parking_countdown_sec": int,
|
||||
"confirm_clear_sec": int, "cooldown_sec": int,
|
||||
},
|
||||
"garbage": {
|
||||
"confirm_garbage_sec": int, "confirm_clear_sec": int,
|
||||
"cooldown_sec": int,
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, working_hours: Optional[List[Dict]] = None):
|
||||
@@ -1617,6 +1862,12 @@ class AlgorithmManager:
|
||||
"cooldown_sec": 900,
|
||||
"target_classes": ["bicycle", "motorcycle"],
|
||||
},
|
||||
"garbage": {
|
||||
"confirm_garbage_sec": 60,
|
||||
"confirm_clear_sec": 60,
|
||||
"cooldown_sec": 1800,
|
||||
"target_classes": ["garbage"],
|
||||
},
|
||||
}
|
||||
|
||||
self._pubsub = None
|
||||
@@ -1904,6 +2155,36 @@ class AlgorithmManager:
|
||||
alarm_level=configured_alarm_level,
|
||||
)
|
||||
logger.info(f"已从Redis加载非机动车违停算法: {key}")
|
||||
elif algo_code == "garbage":
|
||||
configured_alarm_level = params.get("alarm_level")
|
||||
algo_params = {
|
||||
"confirm_garbage_sec": params.get("confirm_garbage_sec", 60),
|
||||
"confirm_clear_sec": params.get("confirm_clear_sec", 60),
|
||||
"cooldown_sec": params.get("cooldown_sec", 1800),
|
||||
"target_classes": params.get("target_classes", ["garbage"]),
|
||||
}
|
||||
if key in self.algorithms.get(roi_id, {}) and "garbage" in self.algorithms[roi_id].get(key, {}):
|
||||
algo = self.algorithms[roi_id][key]["garbage"]
|
||||
algo.confirm_garbage_sec = algo_params["confirm_garbage_sec"]
|
||||
algo.confirm_clear_sec = algo_params["confirm_clear_sec"]
|
||||
algo.cooldown_sec = algo_params["cooldown_sec"]
|
||||
algo.target_classes = algo_params["target_classes"]
|
||||
if configured_alarm_level is not None:
|
||||
algo._alarm_level = configured_alarm_level
|
||||
logger.info(f"已热更新垃圾检测算法参数: {key}")
|
||||
else:
|
||||
if roi_id not in self.algorithms:
|
||||
self.algorithms[roi_id] = {}
|
||||
if key not in self.algorithms[roi_id]:
|
||||
self.algorithms[roi_id][key] = {}
|
||||
self.algorithms[roi_id][key]["garbage"] = GarbageDetectionAlgorithm(
|
||||
confirm_garbage_sec=algo_params["confirm_garbage_sec"],
|
||||
confirm_clear_sec=algo_params["confirm_clear_sec"],
|
||||
cooldown_sec=algo_params["cooldown_sec"],
|
||||
target_classes=algo_params["target_classes"],
|
||||
alarm_level=configured_alarm_level,
|
||||
)
|
||||
logger.info(f"已从Redis加载垃圾检测算法: {key}")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -2044,6 +2325,18 @@ class AlgorithmManager:
|
||||
|
||||
logger.info(f"[{roi_id}_{bind_id}] 更新非机动车违停检测参数")
|
||||
|
||||
elif algo_code == "garbage":
|
||||
existing_algo.confirm_garbage_sec = params.get("confirm_garbage_sec", 60)
|
||||
existing_algo.confirm_clear_sec = params.get("confirm_clear_sec", 60)
|
||||
existing_algo.cooldown_sec = params.get("cooldown_sec", 1800)
|
||||
if "target_classes" in params:
|
||||
existing_algo.target_classes = params["target_classes"]
|
||||
alarm_level = params.get("alarm_level")
|
||||
if alarm_level is not None:
|
||||
existing_algo._alarm_level = alarm_level
|
||||
|
||||
logger.info(f"[{roi_id}_{bind_id}] 更新垃圾检测参数")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -2200,6 +2493,14 @@ class AlgorithmManager:
|
||||
target_classes=algo_params.get("target_classes", ["bicycle", "motorcycle"]),
|
||||
alarm_level=configured_alarm_level,
|
||||
)
|
||||
elif algorithm_type == "garbage":
|
||||
self.algorithms[roi_id][key]["garbage"] = GarbageDetectionAlgorithm(
|
||||
confirm_garbage_sec=algo_params.get("confirm_garbage_sec", 60),
|
||||
confirm_clear_sec=algo_params.get("confirm_clear_sec", 60),
|
||||
cooldown_sec=algo_params.get("cooldown_sec", 1800),
|
||||
target_classes=algo_params.get("target_classes", ["garbage"]),
|
||||
alarm_level=configured_alarm_level,
|
||||
)
|
||||
|
||||
self._registered_keys.add(cache_key)
|
||||
|
||||
@@ -2292,7 +2593,7 @@ class AlgorithmManager:
|
||||
"state": getattr(algo, "state", "WAITING"),
|
||||
"alarm_sent": getattr(algo, "alarm_sent", False),
|
||||
}
|
||||
elif algo_type in ("illegal_parking", "vehicle_congestion", "non_motor_vehicle_parking"):
|
||||
elif algo_type in ("illegal_parking", "vehicle_congestion", "non_motor_vehicle_parking", "garbage"):
|
||||
status[f"{algo_type}_{bind_id}"] = algo.get_state()
|
||||
else:
|
||||
status[f"{algo_type}_{bind_id}"] = {
|
||||
|
||||
Reference in New Issue
Block a user