Spaces:
Runtime error
Runtime error
Upload 3 files
Browse files- README.md +8 -7
- app.py +488 -0
- requirements.txt +3 -0
README.md
CHANGED
|
@@ -1,13 +1,14 @@
|
|
| 1 |
---
|
| 2 |
-
title: Behavioral ML
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
-
license: mit
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
| 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
|