munals commited on
Commit
1f859b0
Β·
verified Β·
1 Parent(s): b274069

Update backend/agents/risk_agent.py

Browse files
Files changed (1) hide show
  1. 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 : Sliding Window + Weighted Scoring
6
-
7
- Scoring Method: Fruin LoS + ROC + Spatial Grid (4-path)
8
- --------------------------------------------------------
9
- Based on:
10
- - Fruin (1971): count-based density proxy β†’ Path 1 + Path 2
11
- - Fruin (1993) FIST model: rate-of-change is critical β†’ Path 3
12
- - UQU Haram Research: local clustering detection via spatial grid β†’ Path 4
13
-
14
- Four detection paths (final score = max of all four):
15
-
16
- Path 1 β€” FRUIN SMOOTH (EMA):
17
- EMA of window-peak count, normalized to HIGH_COUNT.
18
- W_DENSITY=0.60: c_score=1.0 alone exceeds HIGH_THRESHOLD=0.65.
19
- Structural ceiling bug fix (was 0.50 β†’ max 0.61, HIGH unreachable).
20
-
21
- Path 2 β€” INSTANT (zero lag):
22
- current frame count >= HIGH_COUNT β†’ floor 0.70 immediately.
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
- # ── Fruin-calibrated weights ──────────────────────────────────────
59
- W_DENSITY = 0.60 # count-based primary (Fruin LoS proxy)
60
- W_ROC = 0.20 # rate-of-change escalation signal
61
- W_TREND = 0.10 # sliding-window trend
 
62
 
63
- # ── ROC thresholds ────────────────────────────────────────────────
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
- # ── Spatial grid threshold (UQU research) ─────────────────────────
69
- # HIGH_COUNT / 4: a single 3Γ—3 cell with this many persons = local crush risk
70
- # e.g. HIGH_COUNT=50 β†’ 12 persons in one cell of a ~640Γ—360 sub-zone
71
- GRID_CELL_HIGH = max(1, config.RISK_HIGH_COUNT // 4)
 
72
 
73
  def __init__(self):
74
- self.name = 'RiskAgent'
75
- self.aisa_layer = 'Cognitive Agent Layer'
76
- self._window = deque(maxlen=self.WINDOW_SIZE)
77
- self._roc_buf = deque(maxlen=self.ROC_WINDOW)
78
- self._prev_level = 'LOW'
79
- self._peak_ema = 0.0
80
- self._occ_ema = 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  print(
82
- f'⚠️ [RiskAgent] Ready β€” Fruin LoS + ROC + Spatial Grid '
83
- f'(4-path, cell_high={self.GRID_CELL_HIGH})'
 
84
  )
85
 
86
- def _label(self, score: float) -> str:
87
- if score >= self.HIGH_THRESHOLD: return 'HIGH'
88
- if score >= self.MED_THRESHOLD: return 'MEDIUM'
 
 
 
89
  return 'LOW'
90
 
91
  def _compute_trend(self) -> str:
92
  if len(self._window) < 6:
93
  return 'stable'
94
- densities = [f.density_score for f in self._window]
95
- half = len(densities) // 2
96
- delta = np.mean(densities[half:]) - np.mean(densities[:half])
97
- if delta > 0.05: return 'rising'
98
- if delta < -0.05: return 'falling'
99
  return 'stable'
100
 
101
- def process_frame(self, fr: FrameResult) -> RiskResult:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  """
103
- 4-path scoring: final = max(smooth, instant, preemptive, spatial).
 
104
  """
105
- self._window.append(fr)
106
- self._roc_buf.append(fr.person_count)
107
-
108
- counts = [f.person_count for f in self._window]
109
- spacings = [f.avg_spacing for f in self._window if f.avg_spacing < 999]
 
110
 
111
- # ── Path 1: Fruin Smooth EMA ──────────────────────────────────
112
- current_count = float(fr.person_count)
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
- roc_score = max(min(w_roc / 15.0, 1.0), -0.5)
130
- if w_roc > 5:
131
- t_score = 0.8
132
- elif w_roc < -5:
133
- t_score = 0.0
 
 
 
 
 
134
  else:
135
- t_score = 0.3
136
-
137
- smooth_score = max(
138
- c_score * self.W_DENSITY +
139
- roc_score * self.W_ROC +
140
- t_score * self.W_TREND,
141
- 0.0
142
- )
143
-
144
- # ── Path 2: Instant floor ─────────────────────────────────────
145
- instant_floor = 0.70 if fr.person_count >= config.RISK_HIGH_COUNT else 0.0
146
-
147
- # ── Path 3: Pre-emptive ROC ───────────────────────────────────
148
- preemptive = 0.0
149
- if (len(self._roc_buf) == self.ROC_WINDOW
150
- and w_roc >= self.PRE_ROC
151
- and self._peak_ema >= self.PRE_EMA):
152
- preemptive = 0.66
153
-
154
- # ── Path 4: Spatial clustering (UQU Haram research) ──────────
155
- # If any single cell in the 3Γ—3 grid has >= GRID_CELL_HIGH persons
156
- # β†’ local crush risk regardless of global count β†’ floor 0.70.
157
- # This catches Mataf-style bottlenecks: dense corner, sparse rest.
158
- spatial_floor = 0.0
159
- if hasattr(fr, 'grid_max') and fr.grid_max >= self.GRID_CELL_HIGH:
160
- spatial_floor = 0.70
161
- # Only log when it's the deciding factor
162
- if spatial_floor > max(smooth_score, instant_floor, preemptive):
 
 
 
 
 
 
163
  print(
164
- f' πŸ—ΊοΈ [RiskAgent] Spatial hotspot: {fr.hotspot_zone} '
165
- f'({fr.grid_max}p in cell, threshold={self.GRID_CELL_HIGH})'
 
166
  )
 
 
 
 
 
 
 
 
167
 
168
- # ── Final: max of all four paths ──────────────────────────────
169
- risk_score = round(
170
- float(np.clip(
171
- max(smooth_score, instant_floor, preemptive, spatial_floor),
172
- 0.0, 1.0
173
- )),
174
- 4
175
  )
176
 
177
- risk_level = self._label(risk_score)
178
- level_changed = risk_level != self._prev_level
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 = round(self._occ_ema, 1),
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
+ )