Spaces:
Running
Running
Update backend/agents/risk_agent.py
Browse files- backend/agents/risk_agent.py +156 -146
backend/agents/risk_agent.py
CHANGED
|
@@ -2,42 +2,24 @@
|
|
| 2 |
HaramGuard β RiskAgent
|
| 3 |
========================
|
| 4 |
AISA Layer : Cognitive Agent Layer
|
| 5 |
-
Design Pattern :
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
---
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
Fixes Scene D EMA convergence lag (~15 frames).
|
| 24 |
-
|
| 25 |
-
Path 3 β PRE-EMPTIVE ROC:
|
| 26 |
-
5-frame growth >= PRE_ROC AND EMA >= PRE_EMA β floor 0.66.
|
| 27 |
-
Early escalation detection before count crosses HIGH_COUNT.
|
| 28 |
-
Full buffer required (prevents cold-start false positives).
|
| 29 |
-
|
| 30 |
-
Path 4 β SPATIAL CLUSTERING (UQU research):
|
| 31 |
-
If any single 3Γ3 grid cell has >= GRID_CELL_HIGH persons β floor 0.70.
|
| 32 |
-
Catches local hotspots that global count misses:
|
| 33 |
-
47 persons in one corner = HIGH risk, even if global score = MEDIUM.
|
| 34 |
-
GRID_CELL_HIGH = HIGH_COUNT / 4 β 12 persons per cell.
|
| 35 |
-
|
| 36 |
-
References:
|
| 37 |
-
Fruin, J.J. (1971). Pedestrian Planning and Design.
|
| 38 |
-
Fruin, J.J. (1993). Causes and Prevention of Crowd Disasters. ICES.
|
| 39 |
-
UQU / Hajj-Crowd-2021: Haram-specific crowd density research.
|
| 40 |
-
Still, G.K. (2000). Crowd Dynamics. PhD, Warwick University.
|
| 41 |
"""
|
| 42 |
|
| 43 |
import numpy as np
|
|
@@ -49,139 +31,167 @@ import config
|
|
| 49 |
|
| 50 |
class RiskAgent:
|
| 51 |
|
| 52 |
-
WINDOW_SIZE = config.RISK_WINDOW_SIZE
|
| 53 |
-
HIGH_THRESHOLD = config.RISK_HIGH_THRESHOLD # 0.65
|
| 54 |
-
MED_THRESHOLD = config.RISK_MED_THRESHOLD # 0.35
|
| 55 |
-
HIGH_DENSITY = config.RISK_HIGH_DENSITY
|
| 56 |
-
EMA_ALPHA = config.RISK_EMA_ALPHA # 0.6
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
| 62 |
|
| 63 |
-
#
|
| 64 |
-
ROC_WINDOW = 5
|
| 65 |
-
PRE_ROC = 10 # persons growth over 5 frames = rapid escalation
|
| 66 |
-
PRE_EMA = 38 # ~75% of HIGH_COUNT before pre-empting
|
| 67 |
|
| 68 |
-
#
|
| 69 |
-
#
|
| 70 |
-
|
| 71 |
-
|
|
|
|
| 72 |
|
| 73 |
def __init__(self):
|
| 74 |
-
self.name
|
| 75 |
-
self.aisa_layer
|
| 76 |
-
|
| 77 |
-
self.
|
| 78 |
-
self.
|
| 79 |
-
self.
|
| 80 |
-
self.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
print(
|
| 82 |
-
f'β οΈ [RiskAgent] Ready β
|
| 83 |
-
f'(
|
|
|
|
| 84 |
)
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
if
|
|
|
|
|
|
|
|
|
|
| 89 |
return 'LOW'
|
| 90 |
|
| 91 |
def _compute_trend(self) -> str:
|
| 92 |
if len(self._window) < 6:
|
| 93 |
return 'stable'
|
| 94 |
-
|
| 95 |
-
half = len(
|
| 96 |
-
delta = np.mean(
|
| 97 |
-
if delta >
|
| 98 |
-
if delta < -
|
| 99 |
return 'stable'
|
| 100 |
|
| 101 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
"""
|
| 103 |
-
|
|
|
|
| 104 |
"""
|
| 105 |
-
|
| 106 |
-
self.
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
|
|
|
| 110 |
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
self._peak_ema = (
|
| 114 |
-
self.EMA_ALPHA * current_count +
|
| 115 |
-
(1.0 - self.EMA_ALPHA) * self._peak_ema
|
| 116 |
-
)
|
| 117 |
-
c_score = min(self._peak_ema / config.RISK_HIGH_COUNT, 1.0)
|
| 118 |
-
|
| 119 |
-
avg_sp = np.mean(spacings) if spacings else 999
|
| 120 |
-
s_score = max(0.0, 1.0 - avg_sp / config.RISK_SPACING_REF)
|
| 121 |
-
|
| 122 |
-
trend = self._compute_trend()
|
| 123 |
-
|
| 124 |
-
if len(self._roc_buf) == self.ROC_WINDOW:
|
| 125 |
-
w_roc = self._roc_buf[-1] - self._roc_buf[0]
|
| 126 |
-
else:
|
| 127 |
-
w_roc = 0
|
| 128 |
|
| 129 |
-
|
| 130 |
-
if
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
else:
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
print(
|
| 164 |
-
f'
|
| 165 |
-
f'({
|
|
|
|
| 166 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
max(smooth_score, instant_floor, preemptive, spatial_floor),
|
| 172 |
-
0.0, 1.0
|
| 173 |
-
)),
|
| 174 |
-
4
|
| 175 |
)
|
| 176 |
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
self._prev_level = risk_level
|
| 180 |
-
|
| 181 |
-
self._occ_ema = (
|
| 182 |
-
self.EMA_ALPHA * fr.occupation_pct +
|
| 183 |
-
(1.0 - self.EMA_ALPHA) * self._occ_ema
|
| 184 |
-
)
|
| 185 |
|
| 186 |
return RiskResult(
|
| 187 |
frame_id = fr.frame_id,
|
|
@@ -193,5 +203,5 @@ class RiskAgent:
|
|
| 193 |
window_avg = round(float(np.mean(counts)), 1),
|
| 194 |
window_max = int(max(counts)),
|
| 195 |
density_ema = round(self._peak_ema, 1),
|
| 196 |
-
density_pct =
|
| 197 |
-
)
|
|
|
|
| 2 |
HaramGuard β RiskAgent
|
| 3 |
========================
|
| 4 |
AISA Layer : Cognitive Agent Layer
|
| 5 |
+
Design Pattern : Clip Segmentation + Sliding K-Window Density
|
| 6 |
+
|
| 7 |
+
A) Clip segmentation
|
| 8 |
+
- Boundary if |persons[t] - persons[t-1]| >= P_JUMP
|
| 9 |
+
OR |density_score[t] - density_score[t-1]| >= D_JUMP
|
| 10 |
+
- Glitch filter: boundary must persist >= MIN_LEN frames; otherwise merge back.
|
| 11 |
+
|
| 12 |
+
B) Sliding-window density (updates EVERY frame)
|
| 13 |
+
- Keep a deque of the last K=17 frames within the current clip.
|
| 14 |
+
- N_est = count of UNIQUE track IDs across those K frames (union).
|
| 15 |
+
- density_pct = N_est / N_REF * 100 (N_REF = RISK_HIGH_THRESHOLD from config, capped at 100%)
|
| 16 |
+
|
| 17 |
+
C) Risk score 0β1.0 based on density_pct
|
| 18 |
+
- risk_score = density_pct / 100 (0.0β1.0 for downstream compatibility)
|
| 19 |
+
- risk_level: density_pct <= 20 β LOW, <= 80 β MEDIUM, > 80 β HIGH
|
| 20 |
+
|
| 21 |
+
Level stabilization: a new level must hold for STABLE_FRAMES consecutive
|
| 22 |
+
frames before it is confirmed and level_changed fires.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
"""
|
| 24 |
|
| 25 |
import numpy as np
|
|
|
|
| 31 |
|
| 32 |
class RiskAgent:
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
+
P_JUMP = getattr(config, 'CLIP_P_JUMP', 40) # person count jump
|
| 36 |
+
D_JUMP = getattr(config, 'CLIP_D_JUMP', 0.4) # density_score jump
|
| 37 |
+
MIN_LEN = getattr(config, 'CLIP_MIN_LEN', 10) # min frames to confirm clip
|
| 38 |
+
|
| 39 |
+
K_WINDOW = getattr(config, 'CLIP_K_WINDOW', 17) # frames for unique-ID union
|
| 40 |
|
| 41 |
+
N_REF = config.RISK_HIGH_THRESHOLD # density_pct = N_est / N_REF * 100
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
+
LOW_CEIL = 20.0 # density_pct <= 20 β LOW
|
| 44 |
+
MED_CEIL = 80.0 # density_pct <= 80 β MEDIUM, > 80 β HIGH
|
| 45 |
+
|
| 46 |
+
STABLE_FRAMES = 5
|
| 47 |
+
EMA_ALPHA = config.RISK_EMA_ALPHA
|
| 48 |
|
| 49 |
def __init__(self):
|
| 50 |
+
self.name = 'RiskAgent'
|
| 51 |
+
self.aisa_layer = 'Cognitive Agent Layer'
|
| 52 |
+
|
| 53 |
+
self._clip_id = 0
|
| 54 |
+
self._boundary_buf = 0 # consecutive boundary-candidate frames
|
| 55 |
+
self._in_boundary = False # currently in a boundary glitch zone
|
| 56 |
+
self._prev_count = 0
|
| 57 |
+
self._prev_density_sc = 0.0
|
| 58 |
+
|
| 59 |
+
self._k_deque = deque(maxlen=self.K_WINDOW) # last K FrameResults in clip
|
| 60 |
+
self._n_est = 0
|
| 61 |
+
self._density_pct = 0.0
|
| 62 |
+
|
| 63 |
+
self._prev_level = 'LOW'
|
| 64 |
+
self._candidate = None
|
| 65 |
+
self._candidate_count = 0
|
| 66 |
+
|
| 67 |
+
self._peak_ema = 0.0
|
| 68 |
+
self._occ_ema = 0.0
|
| 69 |
+
self._window = deque(maxlen=config.RISK_WINDOW_SIZE)
|
| 70 |
+
|
| 71 |
print(
|
| 72 |
+
f'β οΈ [RiskAgent] Ready β Clip segmentation + sliding K-window({self.K_WINDOW}) '
|
| 73 |
+
f'(P_JUMP={self.P_JUMP}, D_JUMP={self.D_JUMP}, '
|
| 74 |
+
f'MIN_LEN={self.MIN_LEN}, LOW<={self.LOW_CEIL}%, MED<={self.MED_CEIL}%)'
|
| 75 |
)
|
| 76 |
|
| 77 |
+
|
| 78 |
+
def _label(self, density_pct: float) -> str:
|
| 79 |
+
if density_pct > self.MED_CEIL:
|
| 80 |
+
return 'HIGH'
|
| 81 |
+
if density_pct > self.LOW_CEIL:
|
| 82 |
+
return 'MEDIUM'
|
| 83 |
return 'LOW'
|
| 84 |
|
| 85 |
def _compute_trend(self) -> str:
|
| 86 |
if len(self._window) < 6:
|
| 87 |
return 'stable'
|
| 88 |
+
counts = [f.person_count for f in self._window]
|
| 89 |
+
half = len(counts) // 2
|
| 90 |
+
delta = np.mean(counts[half:]) - np.mean(counts[:half])
|
| 91 |
+
if delta > 3: return 'rising'
|
| 92 |
+
if delta < -3: return 'falling'
|
| 93 |
return 'stable'
|
| 94 |
|
| 95 |
+
def _is_boundary(self, fr: FrameResult) -> bool:
|
| 96 |
+
"""Check if this frame is a clip boundary candidate."""
|
| 97 |
+
p_jump = abs(fr.person_count - self._prev_count) >= self.P_JUMP
|
| 98 |
+
d_jump = abs(fr.density_score - self._prev_density_sc) >= self.D_JUMP
|
| 99 |
+
return p_jump or d_jump
|
| 100 |
+
|
| 101 |
+
def _reset_clip(self, frame_id: int, reason: str):
|
| 102 |
+
"""Reset all clip state for a new clip segment."""
|
| 103 |
+
self._clip_id += 1
|
| 104 |
+
self._k_deque.clear()
|
| 105 |
+
self._n_est = 0
|
| 106 |
+
self._density_pct = 0.0
|
| 107 |
+
self._prev_level = 'LOW'
|
| 108 |
+
self._candidate = None
|
| 109 |
+
self._candidate_count = 0
|
| 110 |
+
self._peak_ema = 0.0
|
| 111 |
+
self._occ_ema = 0.0
|
| 112 |
+
print(f'π¬ [RiskAgent] Clip {self._clip_id} started at frame {frame_id} ({reason})')
|
| 113 |
+
|
| 114 |
+
def _update_density(self):
|
| 115 |
"""
|
| 116 |
+
Recompute N_est and density_pct from the current sliding K-window.
|
| 117 |
+
Called every frame β the deque always holds the latest K frames.
|
| 118 |
"""
|
| 119 |
+
all_ids = set()
|
| 120 |
+
for f in self._k_deque:
|
| 121 |
+
for tid in f.track_ids:
|
| 122 |
+
all_ids.add(tid)
|
| 123 |
+
self._n_est = len(all_ids)
|
| 124 |
+
self._density_pct = round(min(self._n_est / self.N_REF * 100, 100.0), 1)
|
| 125 |
|
| 126 |
+
def process_frame(self, fr: FrameResult) -> RiskResult:
|
| 127 |
+
self._window.append(fr)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
+
# ββ A) Clip boundary detection with glitch filtering βββββ
|
| 130 |
+
if fr.frame_id > 1 and self._is_boundary(fr):
|
| 131 |
+
self._boundary_buf += 1
|
| 132 |
+
if self._boundary_buf >= self.MIN_LEN:
|
| 133 |
+
# Confirmed boundary β start new clip
|
| 134 |
+
self._reset_clip(fr.frame_id, f'boundary held {self._boundary_buf} frames')
|
| 135 |
+
self._boundary_buf = 0
|
| 136 |
+
self._in_boundary = False
|
| 137 |
+
else:
|
| 138 |
+
self._in_boundary = True
|
| 139 |
else:
|
| 140 |
+
if self._in_boundary and self._boundary_buf < self.MIN_LEN:
|
| 141 |
+
# Glitch β boundary didn't persist, merge back
|
| 142 |
+
self._boundary_buf = 0
|
| 143 |
+
self._in_boundary = False
|
| 144 |
+
self._boundary_buf = 0
|
| 145 |
+
|
| 146 |
+
self._prev_count = fr.person_count
|
| 147 |
+
self._prev_density_sc = fr.density_score
|
| 148 |
+
|
| 149 |
+
self._k_deque.append(fr)
|
| 150 |
+
self._update_density()
|
| 151 |
+
|
| 152 |
+
density_pct = self._density_pct
|
| 153 |
+
risk_score = round(density_pct / 100.0, 4) # 0.0β1.0
|
| 154 |
+
|
| 155 |
+
raw_level = self._label(density_pct)
|
| 156 |
+
|
| 157 |
+
# Suppress level_changed until K-window is full (warmup period).
|
| 158 |
+
# Prevents false P0/P1 triggers in the first K frames of each clip.
|
| 159 |
+
k_ready = len(self._k_deque) >= self.K_WINDOW
|
| 160 |
+
|
| 161 |
+
if raw_level != self._prev_level:
|
| 162 |
+
if raw_level == self._candidate:
|
| 163 |
+
self._candidate_count += 1
|
| 164 |
+
else:
|
| 165 |
+
self._candidate = raw_level
|
| 166 |
+
self._candidate_count = 1
|
| 167 |
+
|
| 168 |
+
if k_ready and self._candidate_count >= self.STABLE_FRAMES:
|
| 169 |
+
risk_level = raw_level
|
| 170 |
+
level_changed = True
|
| 171 |
+
self._prev_level = raw_level
|
| 172 |
+
self._candidate = None
|
| 173 |
+
self._candidate_count = 0
|
| 174 |
print(
|
| 175 |
+
f'π [RiskAgent] Level confirmed: {risk_level} '
|
| 176 |
+
f'(density_pct={density_pct:.1f}%, N_est={self._n_est}, '
|
| 177 |
+
f'frame={fr.frame_id})'
|
| 178 |
)
|
| 179 |
+
else:
|
| 180 |
+
risk_level = self._prev_level
|
| 181 |
+
level_changed = False
|
| 182 |
+
else:
|
| 183 |
+
risk_level = self._prev_level
|
| 184 |
+
level_changed = False
|
| 185 |
+
self._candidate = None
|
| 186 |
+
self._candidate_count = 0
|
| 187 |
|
| 188 |
+
self._peak_ema = (
|
| 189 |
+
self.EMA_ALPHA * float(fr.person_count) +
|
| 190 |
+
(1.0 - self.EMA_ALPHA) * self._peak_ema
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
)
|
| 192 |
|
| 193 |
+
trend = self._compute_trend()
|
| 194 |
+
counts = [f.person_count for f in self._window]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
|
| 196 |
return RiskResult(
|
| 197 |
frame_id = fr.frame_id,
|
|
|
|
| 203 |
window_avg = round(float(np.mean(counts)), 1),
|
| 204 |
window_max = int(max(counts)),
|
| 205 |
density_ema = round(self._peak_ema, 1),
|
| 206 |
+
density_pct = density_pct,
|
| 207 |
+
)
|