Marcel0123 commited on
Commit
7d8c9ec
·
verified ·
1 Parent(s): 6d5258d

Upload 3 files

Browse files
Files changed (3) hide show
  1. README.md +8 -7
  2. app.py +488 -0
  3. requirements.txt +3 -0
README.md CHANGED
@@ -1,13 +1,14 @@
1
  ---
2
- title: Behavioral ML In Virtual Crowds
3
- emoji: 🏃
4
- colorFrom: indigo
5
- colorTo: green
6
  sdk: gradio
7
- sdk_version: 5.49.1
8
  app_file: app.py
9
  pinned: false
10
- license: mit
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
1
  ---
2
+ title: Behavioral ML in Virtual Crowds
3
+ emoji: 🧍‍♂️
4
+ colorFrom: blue
5
+ colorTo: red
6
  sdk: gradio
7
+ sdk_version: "4.36.1"
8
  app_file: app.py
9
  pinned: false
 
10
  ---
11
 
12
+ # Behavioral ML in Virtual Crowds
13
+
14
+ Een interactieve Gradio-simulatie waarin mensen (stippen) reageren op een incident en proberen te ontsnappen uit een zaal.
app.py ADDED
@@ -0,0 +1,488 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import random
3
+ import time
4
+ from dataclasses import dataclass, field
5
+ from typing import List, Optional, Tuple
6
+
7
+ import gradio as gr
8
+ import numpy as np
9
+ from PIL import Image, ImageDraw
10
+
11
+ # =============================
12
+ # Config
13
+ # =============================
14
+ W, H = 900, 540 # canvas size (px)
15
+ ROOM = (60, 40, W - 60, H - 40) # inner room rect (left, top, right, bottom)
16
+ DT = 0.05 # seconds per simulation step (~20 FPS)
17
+ MAX_SPEED_BASE = 65.0 # px/s baseline
18
+ PERSON_RADIUS = 6 # draw radius in px
19
+ NEIGHBOR_RANGE = 55.0 # px (perception)
20
+ EXIT_W, EXIT_H = 28, 68
21
+
22
+ # Colors
23
+ C_BG = (245, 246, 248, 255)
24
+ C_ROOM = (250, 250, 250, 255)
25
+ C_BORDER = (28, 28, 28, 255)
26
+ C_OBS = (175, 178, 185, 255)
27
+ C_EXIT = (52, 132, 255, 255)
28
+ C_CALM = (44, 184, 78, 255)
29
+ C_ALERT = (250, 191, 35, 255)
30
+ C_PANIC = (233, 68, 68, 255)
31
+ C_INCIDENT = (230, 60, 60, 96)
32
+
33
+ # Global running flag (simple & reliable for Spaces)
34
+ RUNNING = False
35
+
36
+
37
+ @dataclass
38
+ class Person:
39
+ x: float
40
+ y: float
41
+ vx: float
42
+ vy: float
43
+ stress: float = 0.0 # 0..1
44
+ state: int = 0 # 0 calm, 1 alert, 2 panic
45
+ evacuated: bool = False
46
+
47
+ def color(self):
48
+ return [C_CALM, C_ALERT, C_PANIC][self.state]
49
+
50
+
51
+ @dataclass
52
+ class ExitZone:
53
+ x: int
54
+ y: int
55
+ w: int = EXIT_W
56
+ h: int = EXIT_H
57
+
58
+ def rect(self):
59
+ return (self.x, self.y, self.x + self.w, self.y + self.h)
60
+
61
+
62
+ @dataclass
63
+ class World:
64
+ width: int = W
65
+ height: int = H
66
+ room: Tuple[int, int, int, int] = ROOM
67
+ people: List[Person] = field(default_factory=list)
68
+ exits: List[ExitZone] = field(default_factory=list)
69
+ obstacles: List[Tuple[int, int, int, int]] = field(default_factory=list)
70
+ incident: Optional[Tuple[int, int, int]] = None # (cx, cy, radius)
71
+
72
+ # Params (controlled by UI)
73
+ speed_scale: float = 1.0
74
+ cohesion: float = 0.3
75
+ separation: float = 0.6
76
+ panic_spread: float = 2.0
77
+ personal_space: float = 22.0
78
+
79
+ # cached metrics
80
+ evac_count: int = 0
81
+ avg_stress: float = 0.0
82
+ collisions_proxy: int = 0
83
+
84
+ def reset_metrics(self):
85
+ self.evac_count = sum(1 for p in self.people if p.evacuated)
86
+ if self.people:
87
+ self.avg_stress = float(np.mean([p.stress for p in self.people if not p.evacuated])) if any(
88
+ not p.evacuated for p in self.people) else 0.0
89
+ else:
90
+ self.avg_stress = 0.0
91
+ # simple collisions proxy: count of pairs closer than 0.8 * personal_space (approx)
92
+ close = 0
93
+ active = [p for p in self.people if not p.evacuated]
94
+ n = len(active)
95
+ for i in range(n):
96
+ pi = active[i]
97
+ for j in range(i + 1, n):
98
+ pj = active[j]
99
+ dx = pi.x - pj.x
100
+ dy = pi.y - pj.y
101
+ if dx * dx + dy * dy < (0.8 * self.personal_space) ** 2:
102
+ close += 1
103
+ self.collisions_proxy = close
104
+
105
+ def step(self, dt: float):
106
+ l, t, r, b = self.room
107
+
108
+ def nearest_exit_vec(px, py):
109
+ best = None
110
+ best_d2 = 1e12
111
+ for ex in self.exits:
112
+ cx = ex.x + ex.w / 2
113
+ cy = ex.y + ex.h / 2
114
+ dx, dy = (cx - px), (cy - py)
115
+ d2 = dx * dx + dy * dy
116
+ if d2 < best_d2:
117
+ best_d2 = d2
118
+ best = (dx, dy)
119
+ return best if best is not None else (0.0, 0.0)
120
+
121
+ # update states (stress diffusion + incident influence)
122
+ for p in self.people:
123
+ if p.evacuated:
124
+ continue
125
+
126
+ # incident influence
127
+ if self.incident is not None:
128
+ cx, cy, rad = self.incident
129
+ dx = p.x - cx
130
+ dy = p.y - cy
131
+ d = math.hypot(dx, dy)
132
+ if d < rad:
133
+ # stronger effect closer to center
134
+ p.stress += (1 - d / rad) * self.panic_spread * 0.18 * dt
135
+
136
+ # neighbor panic influence (cheap/approx)
137
+ # random subset to keep O(n)
138
+ for _ in range(6):
139
+ q = random.choice(self.people)
140
+ if q.evacuated:
141
+ continue
142
+ if q is p:
143
+ continue
144
+ dx = q.x - p.x
145
+ dy = q.y - p.y
146
+ d2 = dx * dx + dy * dy
147
+ if d2 < (NEIGHBOR_RANGE ** 2) and q.state == 2:
148
+ p.stress += self.panic_spread * 0.04 * dt
149
+
150
+ # clamp and assign state
151
+ p.stress = max(0.0, min(1.0, p.stress))
152
+ if p.stress > 0.65:
153
+ p.state = 2
154
+ elif p.stress > 0.35:
155
+ p.state = 1
156
+ else:
157
+ p.state = 0
158
+
159
+ # forces & motion
160
+ for i, p in enumerate(self.people):
161
+ if p.evacuated:
162
+ continue
163
+
164
+ # skip if already in exit
165
+ for ex in self.exits:
166
+ x0, y0, x1, y1 = ex.rect()
167
+ if x0 <= p.x <= x1 and y0 <= p.y <= y1:
168
+ p.evacuated = True
169
+ continue
170
+ if p.evacuated:
171
+ continue
172
+
173
+ # neighborhood (very light)
174
+ # sample 20 random neighbors instead of full n^2
175
+ neighbors = random.sample(self.people, k=min(20, len(self.people)))
176
+
177
+ # separation
178
+ fx, fy = 0.0, 0.0
179
+ for q in neighbors:
180
+ if q is p or q.evacuated:
181
+ continue
182
+ dx = p.x - q.x
183
+ dy = p.y - q.y
184
+ d2 = dx * dx + dy * dy
185
+ if d2 == 0:
186
+ continue
187
+ if d2 < (self.personal_space ** 2):
188
+ inv = 1.0 / max(1.0, d2)
189
+ fx += dx * inv
190
+ fy += dy * inv
191
+
192
+ # cohesion & alignment
193
+ cx, cy, cvx, cvy, cnt = 0.0, 0.0, 0.0, 0.0, 0
194
+ for q in neighbors:
195
+ if q is p or q.evacuated:
196
+ continue
197
+ dx = q.x - p.x
198
+ dy = q.y - p.y
199
+ if dx * dx + dy * dy < NEIGHBOR_RANGE ** 2:
200
+ cx += q.x
201
+ cy += q.y
202
+ cvx += q.vx
203
+ cvy += q.vy
204
+ cnt += 1
205
+ if cnt > 0:
206
+ cx /= cnt
207
+ cy /= cnt
208
+ cvx /= cnt
209
+ cvy /= cnt
210
+ # cohesion
211
+ fx += (cx - p.x) * self.cohesion * 0.15
212
+ fy += (cy - p.y) * self.cohesion * 0.15
213
+ # alignment
214
+ fx += (cvx - p.vx) * 0.05
215
+ fy += (cvy - p.vy) * 0.05
216
+
217
+ # goal (nearest exit)
218
+ gx, gy = nearest_exit_vec(p.x, p.y)
219
+ # scale stronger when panicking
220
+ goal_k = 0.6 if p.state == 0 else (0.8 if p.state == 1 else 1.1)
221
+ fx += gx * goal_k / 200.0
222
+ fy += gy * goal_k / 200.0
223
+
224
+ # obstacle avoidance (simple push)
225
+ for (ox0, oy0, ox1, oy1) in self.obstacles:
226
+ # compute penetration vector if inside expanded box
227
+ margin = self.personal_space * 0.7
228
+ if (ox0 - margin) < p.x < (ox1 + margin) and (oy0 - margin) < p.y < (oy1 + margin):
229
+ # push outwards from nearest edge
230
+ dx_left = p.x - ox0
231
+ dx_right = ox1 - p.x
232
+ dy_top = p.y - oy0
233
+ dy_bot = oy1 - p.y
234
+ m = min(dx_left, dx_right, dy_top, dy_bot)
235
+ if m == dx_left:
236
+ fx -= 0.8
237
+ elif m == dx_right:
238
+ fx += 0.8
239
+ elif m == dy_top:
240
+ fy -= 0.8
241
+ else:
242
+ fy += 0.8
243
+
244
+ # walls (room bounds)
245
+ x0, y0, x1, y1 = self.room
246
+ wall_margin = 8
247
+ if p.x < x0 + wall_margin:
248
+ fx += 1.2
249
+ if p.x > x1 - wall_margin:
250
+ fx -= 1.2
251
+ if p.y < y0 + wall_margin:
252
+ fy += 1.2
253
+ if p.y > y1 - wall_margin:
254
+ fy -= 1.2
255
+
256
+ # integrate
257
+ p.vx += fx
258
+ p.vy += fy
259
+
260
+ # max speed based on stress
261
+ stress_boost = [1.0, 1.2, 1.6][p.state]
262
+ vmax = MAX_SPEED_BASE * self.speed_scale * stress_boost
263
+ v = math.hypot(p.vx, p.vy)
264
+ if v > vmax:
265
+ p.vx = p.vx / v * vmax
266
+ p.vy = p.vy / v * vmax
267
+
268
+ p.x += p.vx * dt
269
+ p.y += p.vy * dt
270
+
271
+ # clamp to room
272
+ p.x = min(max(p.x, x0 + 2), x1 - 2)
273
+ p.y = min(max(p.y, y0 + 2), y1 - 2)
274
+
275
+ # metrics
276
+ self.reset_metrics()
277
+
278
+ def render(self) -> Image.Image:
279
+ img = Image.new("RGBA", (self.width, self.height), C_BG)
280
+ d = ImageDraw.Draw(img, "RGBA")
281
+
282
+ # room
283
+ d.rectangle(self.room, fill=C_ROOM, outline=C_BORDER, width=3)
284
+
285
+ # incident overlay
286
+ if self.incident is not None:
287
+ cx, cy, rad = self.incident
288
+ d.ellipse((cx - rad, cy - rad, cx + rad, cy + rad), fill=C_INCIDENT)
289
+
290
+ # obstacles
291
+ for (x0, y0, x1, y1) in self.obstacles:
292
+ d.rectangle((x0, y0, x1, y1), fill=C_OBS)
293
+
294
+ # exits
295
+ for ex in self.exits:
296
+ d.rectangle(ex.rect(), fill=C_EXIT)
297
+ label = "Exit"
298
+ d.text((ex.x + 6, ex.y + ex.h / 2 - 7), label, fill=(255, 255, 255, 255))
299
+
300
+ # people
301
+ for p in self.people:
302
+ if p.evacuated:
303
+ continue
304
+ c = p.color()
305
+ d.ellipse((p.x - PERSON_RADIUS, p.y - PERSON_RADIUS,
306
+ p.x + PERSON_RADIUS, p.y + PERSON_RADIUS), fill=c, outline=None)
307
+
308
+ return img
309
+
310
+
311
+ # =============================
312
+ # Helpers to build scenarios
313
+ # =============================
314
+
315
+ def make_world(n_people=200, speed=1.2, cohesion=0.3, separation=0.6, panic_spread=2.4):
316
+ random.seed(7)
317
+ np.random.seed(7)
318
+ world = World()
319
+ world.speed_scale = float(speed)
320
+ world.cohesion = float(cohesion)
321
+ world.separation = float(separation)
322
+ world.panic_spread = float(panic_spread)
323
+ world.personal_space = 22 + 10 * separation
324
+
325
+ # exits on right wall
326
+ ex1 = ExitZone(x=ROOM[2] - 8 - EXIT_W, y=int(ROOM[1] + 70))
327
+ ex2 = ExitZone(x=ROOM[2] - 8 - EXIT_W, y=int(ROOM[3] - 70 - EXIT_H))
328
+ world.exits = [ex1, ex2]
329
+
330
+ # obstacles (columns)
331
+ world.obstacles = [
332
+ (int(ROOM[0] + 220), int(ROOM[1] + 110), int(ROOM[0] + 360), int(ROOM[1] + 150)),
333
+ (int(ROOM[0] + 280), int(ROOM[3] - 150), int(ROOM[0] + 420), int(ROOM[3] - 110)),
334
+ (int(ROOM[2] - 140), int(ROOM[1] + 160), int(ROOM[2] - 110), int(ROOM[1] + 260)),
335
+ ]
336
+
337
+ # crowd initial placement (gaussian blob)
338
+ l, t, r, b = ROOM
339
+ for _ in range(n_people):
340
+ x = np.clip(np.random.normal((l + r) / 2 - 120, 120), l + 20, r - 40)
341
+ y = np.clip(np.random.normal((t + b) / 2, 90), t + 20, b - 20)
342
+ ang = np.random.rand() * 2 * np.pi
343
+ speed0 = np.random.uniform(5, 25)
344
+ world.people.append(Person(x=x, y=y, vx=np.cos(ang) * speed0, vy=np.sin(ang) * speed0))
345
+
346
+ world.reset_metrics()
347
+ return world
348
+
349
+
350
+ def trigger_incident(world: World):
351
+ # incident: top-right of the room
352
+ cx = int(ROOM[2] - 220)
353
+ cy = int(ROOM[1] + 110)
354
+ rad = 150
355
+ world.incident = (cx, cy, rad)
356
+ # give initial stress kick to agents inside
357
+ for p in world.people:
358
+ if p.evacuated:
359
+ continue
360
+ dx, dy = p.x - cx, p.y - cy
361
+ d = math.hypot(dx, dy)
362
+ if d < rad * 0.85:
363
+ p.stress = max(p.stress, 0.7)
364
+
365
+
366
+ def clear_incident(world: World):
367
+ world.incident = None
368
+
369
+
370
+ # =============================
371
+ # Gradio bindings
372
+ # =============================
373
+
374
+ def reset_fn(n_people, speed, cohesion, separation, panic_spread):
375
+ global RUNNING
376
+ RUNNING = False
377
+ world = make_world(int(n_people), float(speed), float(cohesion), float(separation), float(panic_spread))
378
+ frame = world.render()
379
+ metrics = {
380
+ "% Evacuated": f"{(world.evac_count / max(1, len(world.people)))*100:.0f}",
381
+ "Avg. Stress": f"{world.avg_stress:.2f}",
382
+ "Collisions": f"{world.collisions_proxy}"
383
+ }
384
+ return frame, metrics, world
385
+
386
+
387
+ def start_incident_fn(world):
388
+ if world is None:
389
+ world = make_world()
390
+ trigger_incident(world)
391
+ return world.render(), {
392
+ "% Evacuated": f"{(world.evac_count / max(1, len(world.people)))*100:.0f}",
393
+ "Avg. Stress": f"{world.avg_stress:.2f}",
394
+ "Collisions": f"{world.collisions_proxy}"
395
+ }, world
396
+
397
+
398
+ def clear_incident_fn(world):
399
+ if world is None:
400
+ world = make_world()
401
+ clear_incident(world)
402
+ return world.render(), {
403
+ "% Evacuated": f"{(world.evac_count / max(1, len(world.people)))*100:.0f}",
404
+ "Avg. Stress": f"{world.avg_stress:.2f}",
405
+ "Collisions": f"{world.collisions_proxy}"
406
+ }, world
407
+
408
+
409
+ def start_fn(world, n_steps=999999):
410
+ """Generator: runs until paused."""
411
+ global RUNNING
412
+ RUNNING = True
413
+ if world is None:
414
+ world = make_world()
415
+ # stream frames
416
+ steps = 0
417
+ while RUNNING and steps < n_steps:
418
+ world.step(DT)
419
+ frame = world.render()
420
+ metrics = {
421
+ "% Evacuated": f"{(world.evac_count / max(1, len(world.people)))*100:.0f}",
422
+ "Avg. Stress": f"{world.avg_stress:.2f}",
423
+ "Collisions": f"{world.collisions_proxy}"
424
+ }
425
+ yield frame, metrics, world
426
+ steps += 1
427
+ time.sleep(DT) # throttle to ~20 FPS
428
+
429
+
430
+ def pause_fn():
431
+ global RUNNING
432
+ RUNNING = False
433
+ return gr.update(), gr.update(), gr.update()
434
+
435
+
436
+ with gr.Blocks(title="Behavioral ML in Virtual Crowds") as demo:
437
+ gr.Markdown(
438
+ "# Behavioral ML in Virtual Crowds · Evacuatie-simulatie\n"
439
+ "Mensen (stippen) reageren op een incident, ontwijken obstakels en zoeken de **Exit**. "
440
+ "Gebruik de knoppen en sliders hieronder."
441
+ )
442
+ with gr.Row():
443
+ canvas = gr.Image(label="Simulatie", interactive=False, type="pil")
444
+ metrics = gr.Label(label="Live metrics")
445
+
446
+ with gr.Row():
447
+ with gr.Column(scale=2):
448
+ n_people = gr.Slider(20, 600, value=200, step=10, label="Aantal mensen")
449
+ speed = gr.Slider(0.5, 2.0, value=1.2, step=0.05, label="Snelheid (schaal)")
450
+ cohesion = gr.Slider(0.0, 1.0, value=0.3, step=0.05, label="Samenhang")
451
+ separation = gr.Slider(0.0, 1.0, value=0.6, step=0.05, label="Persoonlijke ruimte (schaal)")
452
+ panic_spread = gr.Slider(0.0, 4.0, value=2.4, step=0.1, label="Paniekverspreiding")
453
+ with gr.Column(scale=1):
454
+ start_btn = gr.Button("▶️ Start")
455
+ pause_btn = gr.Button("⏸️ Pauze")
456
+ reset_btn = gr.Button("🔁 Reset")
457
+ inc_btn = gr.Button("🚨 Start Incident")
458
+ clear_inc_btn = gr.Button("🧯 Clear Incident")
459
+ state = gr.State() # holds World
460
+
461
+ # Bindings
462
+ reset_btn.click(
463
+ reset_fn,
464
+ inputs=[n_people, speed, cohesion, separation, panic_spread],
465
+ outputs=[canvas, metrics, state],
466
+ api_name="reset",
467
+ )
468
+
469
+ # initialize once at app load
470
+ init_frame, init_metrics, init_world = reset_fn(200, 1.2, 0.3, 0.6, 2.4)
471
+ canvas.value = init_frame
472
+ metrics.value = init_metrics
473
+ state.value = init_world
474
+
475
+ start_btn.click(
476
+ start_fn,
477
+ inputs=[state],
478
+ outputs=[canvas, metrics, state],
479
+ show_progress=False, # smoother streaming
480
+ api_name="start",
481
+ )
482
+
483
+ pause_btn.click(pause_fn, outputs=[canvas, metrics, state], api_name="pause")
484
+ inc_btn.click(start_incident_fn, inputs=[state], outputs=[canvas, metrics, state], api_name="incident")
485
+ clear_inc_btn.click(clear_incident_fn, inputs=[state], outputs=[canvas, metrics, state], api_name="clear_incident")
486
+
487
+ if __name__ == "__main__":
488
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ gradio==4.36.1
2
+ numpy>=1.24,<3.0
3
+ pillow>=10.0.0,<11.0