Update app.py
Browse files
app.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
-
# app.py — Crowd Behavior Lab (
|
| 2 |
-
# ----------------------------------------------------------
|
| 3 |
-
#
|
| 4 |
-
# -
|
| 5 |
-
# -
|
| 6 |
-
# - Geen
|
| 7 |
#
|
| 8 |
-
#
|
| 9 |
# gradio>=4.44.0
|
| 10 |
# numpy>=1.24
|
| 11 |
# matplotlib>=3.8
|
|
@@ -15,8 +15,10 @@
|
|
| 15 |
from __future__ import annotations
|
| 16 |
|
| 17 |
import io
|
|
|
|
| 18 |
import json
|
| 19 |
import math
|
|
|
|
| 20 |
from dataclasses import dataclass, asdict
|
| 21 |
from typing import List, Tuple, Optional, Dict
|
| 22 |
|
|
@@ -25,11 +27,12 @@ from PIL import Image
|
|
| 25 |
import matplotlib
|
| 26 |
matplotlib.use("Agg")
|
| 27 |
import matplotlib.pyplot as plt
|
|
|
|
| 28 |
import gradio as gr
|
| 29 |
|
| 30 |
|
| 31 |
# ---------------------------
|
| 32 |
-
# Theme & CSS
|
| 33 |
# ---------------------------
|
| 34 |
THEME = gr.themes.Soft(primary_hue="indigo", secondary_hue="violet")
|
| 35 |
CUSTOM_CSS = """
|
|
@@ -38,66 +41,46 @@ CUSTOM_CSS = """
|
|
| 38 |
#title h1 { font-weight: 800; letter-spacing: -0.02em; }
|
| 39 |
.card { box-shadow: var(--shadow); border-radius: 18px; padding: 10px; background: white; }
|
| 40 |
.legend-dot { display:inline-block; width:12px; height:12px; border-radius:50%; margin-right:6px; vertical-align:middle; }
|
| 41 |
-
.legend-green { background:#1db954; }
|
| 42 |
-
.
|
| 43 |
-
.legend-red { background:#e02424; } /* rood */
|
| 44 |
-
.stat-badge {
|
| 45 |
-
display:inline-block; padding:6px 10px; background:#f5f5ff; border-radius:12px; margin-right:8px;
|
| 46 |
-
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.05);
|
| 47 |
-
}
|
| 48 |
"""
|
| 49 |
|
| 50 |
|
| 51 |
# ---------------------------
|
| 52 |
-
#
|
| 53 |
# ---------------------------
|
| 54 |
@dataclass
|
| 55 |
class RectObstacle:
|
| 56 |
-
x: float
|
| 57 |
-
y: float
|
| 58 |
-
w: float
|
| 59 |
-
h: float
|
| 60 |
-
|
| 61 |
def contains(self, px: float, py: float) -> bool:
|
| 62 |
return self.x <= px <= self.x + self.w and self.y <= py <= self.y + self.h
|
| 63 |
-
|
| 64 |
def nearest_point(self, p: np.ndarray) -> np.ndarray:
|
| 65 |
qx = np.clip(p[0], self.x, self.x + self.w)
|
| 66 |
qy = np.clip(p[1], self.y, self.y + self.h)
|
| 67 |
return np.array([qx, qy], dtype=np.float32)
|
| 68 |
-
|
| 69 |
def to_json(self) -> Dict:
|
| 70 |
-
|
| 71 |
-
|
| 72 |
|
| 73 |
@dataclass
|
| 74 |
class World:
|
| 75 |
-
width: float = 20.0
|
| 76 |
-
height: float = 12.0
|
| 77 |
-
obstacles: List[RectObstacle] = None
|
| 78 |
-
|
| 79 |
def __post_init__(self):
|
| 80 |
-
if self.obstacles is None:
|
| 81 |
-
self.obstacles = []
|
| 82 |
-
|
| 83 |
|
| 84 |
@dataclass
|
| 85 |
class Agent:
|
| 86 |
-
pos: np.ndarray
|
| 87 |
-
vel: np.ndarray # (2,)
|
| 88 |
-
goal: np.ndarray # (2,)
|
| 89 |
|
| 90 |
|
| 91 |
# ---------------------------
|
| 92 |
-
#
|
| 93 |
# ---------------------------
|
| 94 |
def funnel_presets():
|
| 95 |
presets = {}
|
| 96 |
-
|
| 97 |
-
# Taps toelopende flessenhals (opgebouwd uit segmenten -> kanaal wordt smaller)
|
| 98 |
obs = []
|
| 99 |
world_w, world_h = 20.0, 12.0
|
| 100 |
-
left_x = 2.0
|
| 101 |
steps = 8
|
| 102 |
top_y0, bot_y0 = 9.5, 2.5
|
| 103 |
top_y1, bot_y1 = 6.8, 5.2
|
|
@@ -107,8 +90,8 @@ def funnel_presets():
|
|
| 107 |
t = i / (steps - 1)
|
| 108 |
top_y = (1 - t) * top_y0 + t * top_y1
|
| 109 |
bot_y = (1 - t) * bot_y0 + t * bot_y1
|
| 110 |
-
obs.append(RectObstacle(x0, top_y, x1 - x0, world_h - top_y))
|
| 111 |
-
obs.append(RectObstacle(x0, 0.0, x1 - x0, bot_y))
|
| 112 |
presets["Flessenhals (taps)"] = obs
|
| 113 |
|
| 114 |
# Dubbele funnel
|
|
@@ -133,7 +116,6 @@ def funnel_presets():
|
|
| 133 |
obs2.append(RectObstacle(x0, top_y, x1 - x0, 12.0 - top_y))
|
| 134 |
obs2.append(RectObstacle(x0, 0.0, x1 - x0, bot_y))
|
| 135 |
presets["Dubbele funnel (taps)"] = obs2
|
| 136 |
-
|
| 137 |
return presets
|
| 138 |
|
| 139 |
PRESETS = {
|
|
@@ -145,14 +127,10 @@ PRESETS = {
|
|
| 145 |
}
|
| 146 |
|
| 147 |
DEFAULT_PARAMS = dict(
|
| 148 |
-
desired_speed=1.3,
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
obstacle_repulsion=8.0,
|
| 153 |
-
obstacle_range=1.0,
|
| 154 |
-
noise=0.08,
|
| 155 |
-
bounce_walls=True,
|
| 156 |
)
|
| 157 |
|
| 158 |
|
|
@@ -160,13 +138,12 @@ DEFAULT_PARAMS = dict(
|
|
| 160 |
# Helpers
|
| 161 |
# ---------------------------
|
| 162 |
def parse_obstacles(json_text: str) -> List[RectObstacle]:
|
| 163 |
-
if not json_text or not json_text.strip():
|
| 164 |
-
return []
|
| 165 |
data = json.loads(json_text)
|
| 166 |
return [RectObstacle(float(d["x"]), float(d["y"]), float(d["w"]), float(d["h"])) for d in data]
|
| 167 |
|
| 168 |
def obstacles_to_json(obstacles: List[RectObstacle]) -> str:
|
| 169 |
-
return json.dumps([
|
| 170 |
|
| 171 |
def init_agents(n_agents: int, world: World, layout: str, seed: int = 42) -> List[Agent]:
|
| 172 |
rng = np.random.default_rng(seed)
|
|
@@ -189,7 +166,7 @@ def init_agents(n_agents: int, world: World, layout: str, seed: int = 42) -> Lis
|
|
| 189 |
|
| 190 |
|
| 191 |
# ---------------------------
|
| 192 |
-
#
|
| 193 |
# ---------------------------
|
| 194 |
def social_force_step(agents: List[Agent], world: World, params: dict, dt: float = 0.12) -> None:
|
| 195 |
positions = np.array([a.pos for a in agents])
|
|
@@ -206,16 +183,15 @@ def social_force_step(agents: List[Agent], world: World, params: dict, dt: float
|
|
| 206 |
|
| 207 |
forces = np.zeros_like(positions)
|
| 208 |
|
| 209 |
-
# Driving force
|
| 210 |
goals = np.array([a.goal for a in agents])
|
| 211 |
to_goal = goals - positions
|
| 212 |
dist_goal = np.linalg.norm(to_goal, axis=1) + 1e-6
|
| 213 |
desired_dir = (to_goal.T / dist_goal).T
|
| 214 |
desired_vel = desired_speed * desired_dir
|
| 215 |
-
|
| 216 |
-
forces += driving
|
| 217 |
|
| 218 |
-
# Repulsie
|
| 219 |
for i in range(len(agents)):
|
| 220 |
diff = positions[i] - positions
|
| 221 |
d = np.linalg.norm(diff, axis=1) + 1e-6
|
|
@@ -227,8 +203,7 @@ def social_force_step(agents: List[Agent], world: World, params: dict, dt: float
|
|
| 227 |
|
| 228 |
# Repulsie obstakels
|
| 229 |
for i in range(len(agents)):
|
| 230 |
-
p = positions[i]
|
| 231 |
-
f = np.zeros(2, dtype=np.float32)
|
| 232 |
for ob in world.obstacles:
|
| 233 |
q = ob.nearest_point(p)
|
| 234 |
diff = p - q
|
|
@@ -242,21 +217,19 @@ def social_force_step(agents: List[Agent], world: World, params: dict, dt: float
|
|
| 242 |
# Ruis
|
| 243 |
forces += noise * np.random.randn(*forces.shape)
|
| 244 |
|
| 245 |
-
# Integratie
|
| 246 |
new_vel = velocities + dt * forces
|
| 247 |
speeds = np.linalg.norm(new_vel, axis=1) + 1e-6
|
| 248 |
-
|
| 249 |
-
new_vel = (new_vel.T * np.minimum(1.0, max_speed / speeds)).T
|
| 250 |
new_pos = positions + dt * new_vel
|
| 251 |
|
| 252 |
-
#
|
| 253 |
for i in range(len(agents)):
|
| 254 |
for ob in world.obstacles:
|
| 255 |
if ob.contains(new_pos[i, 0], new_pos[i, 1]):
|
| 256 |
q = ob.nearest_point(new_pos[i])
|
| 257 |
dir_ = new_pos[i] - q
|
| 258 |
-
if np.linalg.norm(dir_) < 1e-6:
|
| 259 |
-
dir_ = np.array([0.5, 0.0], dtype=np.float32)
|
| 260 |
dir_ = dir_ / (np.linalg.norm(dir_) + 1e-6)
|
| 261 |
new_pos[i] = q + 0.06 * dir_
|
| 262 |
|
|
@@ -270,85 +243,57 @@ def social_force_step(agents: List[Agent], world: World, params: dict, dt: float
|
|
| 270 |
|
| 271 |
# Commit
|
| 272 |
for i, a in enumerate(agents):
|
| 273 |
-
a.pos = new_pos[i]
|
| 274 |
-
a.vel = new_vel[i]
|
| 275 |
|
| 276 |
|
| 277 |
# ---------------------------
|
| 278 |
-
# Visual metrics
|
| 279 |
# ---------------------------
|
| 280 |
def k3_distance(point: np.ndarray, all_points: np.ndarray) -> float:
|
| 281 |
dists = np.linalg.norm(all_points - point, axis=1)
|
| 282 |
dists = np.sort(dists[dists > 0])
|
| 283 |
-
if len(dists) == 0:
|
| 284 |
-
|
| 285 |
-
if len(dists) < 3:
|
| 286 |
-
return dists[-1]
|
| 287 |
return dists[2]
|
| 288 |
|
| 289 |
def min_obstacle_distance(p: np.ndarray, world: World) -> float:
|
| 290 |
-
if not world.obstacles:
|
| 291 |
-
|
| 292 |
-
ds = []
|
| 293 |
-
for ob in world.obstacles:
|
| 294 |
-
q = ob.nearest_point(p)
|
| 295 |
-
ds.append(np.linalg.norm(p - q))
|
| 296 |
-
return min(ds) if ds else 5.0
|
| 297 |
|
| 298 |
def stress_score(p: np.ndarray, v: np.ndarray, all_points: np.ndarray, world: World, desired_speed: float) -> float:
|
| 299 |
sv = min(1.0, (np.linalg.norm(v) / max(1e-6, desired_speed)))
|
| 300 |
-
k3 = k3_distance(p, all_points)
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
return
|
| 307 |
-
|
| 308 |
-
def stress_to_color(stress: float) -> str:
|
| 309 |
-
if stress < 0.33:
|
| 310 |
-
return "#1db954" # groen
|
| 311 |
-
elif stress < 0.66:
|
| 312 |
-
return "#ff9f1a" # oranje
|
| 313 |
-
else:
|
| 314 |
-
return "#e02424" # rood
|
| 315 |
|
| 316 |
|
| 317 |
# ---------------------------
|
| 318 |
-
# Rendering (
|
| 319 |
# ---------------------------
|
| 320 |
-
def render_frame(
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
params: dict,
|
| 325 |
-
trail_positions: List[np.ndarray],
|
| 326 |
-
show_trails: bool = True,
|
| 327 |
-
show_heatmap: bool = False,
|
| 328 |
-
show_risk_tiles: bool = False,
|
| 329 |
-
performance_mode: bool = True,
|
| 330 |
-
) -> Image.Image:
|
| 331 |
-
dpi = 95 if performance_mode else 120
|
| 332 |
figsize = (10, 5.6) if performance_mode else (11, 6.2)
|
| 333 |
trail_steps = (4 if performance_mode else 8)
|
| 334 |
draw_halos = (False if performance_mode else True)
|
| 335 |
heatmap_grid = (80, 48) if performance_mode else (100, 60)
|
| 336 |
|
| 337 |
fig, ax = plt.subplots(figsize=figsize)
|
| 338 |
-
ax.set_xlim(0, world.width)
|
| 339 |
-
ax.
|
| 340 |
-
ax.set_aspect("equal")
|
| 341 |
-
ax.set_facecolor("#f9fafb")
|
| 342 |
|
| 343 |
# Obstacles
|
| 344 |
for ob in world.obstacles:
|
| 345 |
-
rect = plt.Rectangle(
|
| 346 |
-
(ob.x, ob.y), ob.w, ob.h,
|
| 347 |
-
facecolor="#6b7280", alpha=0.20, edgecolor="#1f2937", linewidth=1.0 if performance_mode else 1.2
|
| 348 |
-
)
|
| 349 |
ax.add_patch(rect)
|
| 350 |
|
| 351 |
-
# Heatmap
|
| 352 |
if show_heatmap and len(positions) > 3:
|
| 353 |
gx, gy = np.mgrid[0:world.width:complex(heatmap_grid[0]), 0:world.height:complex(heatmap_grid[1])]
|
| 354 |
grid = np.zeros_like(gx, dtype=np.float32)
|
|
@@ -358,57 +303,43 @@ def render_frame(
|
|
| 358 |
if 0 <= ix < grid.shape[0] and 0 <= iy < grid.shape[1]:
|
| 359 |
grid[ix, iy] += 1.0
|
| 360 |
grid = (np.roll(grid, 1, 0) + grid + np.roll(grid, -1, 0) + np.roll(grid, 1, 1) + grid + np.roll(grid, -1, 1)) / 6.0
|
| 361 |
-
ax.imshow(
|
| 362 |
-
grid.T, extent=(0, world.width, 0, world.height),
|
| 363 |
-
origin="lower", cmap="magma", alpha=0.33 if performance_mode else 0.38, interpolation="bilinear",
|
| 364 |
-
)
|
| 365 |
|
| 366 |
-
#
|
| 367 |
if show_risk_tiles:
|
| 368 |
-
tiles_x, tiles_y =
|
| 369 |
-
tx = world.width / tiles_x
|
| 370 |
-
ty = world.height / tiles_y
|
| 371 |
for ix in range(tiles_x):
|
| 372 |
for iy in range(tiles_y):
|
| 373 |
-
mask = (positions[:,
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
ax.add_patch(rect)
|
| 379 |
-
|
| 380 |
-
# Trails
|
| 381 |
if show_trails and trail_positions:
|
| 382 |
steps = min(trail_steps, len(trail_positions))
|
| 383 |
for t_back in range(steps):
|
| 384 |
alpha = 0.10 + 0.10 * (t_back / steps)
|
| 385 |
pts = trail_positions[-1 - t_back]
|
| 386 |
-
ax.scatter(pts[:,
|
| 387 |
|
| 388 |
-
#
|
| 389 |
-
stresses = [stress_score(p, v, positions, world, params.get("desired_speed", 1.3))
|
| 390 |
-
|
| 391 |
-
sizes = (24 if performance_mode else 28) + (40 if performance_mode else 50) * np.array(stresses)
|
| 392 |
colors = [stress_to_color(s) for s in stresses]
|
| 393 |
-
ax.scatter(positions[:,
|
| 394 |
-
linewidths=0.5 if performance_mode else 0.6, zorder=3)
|
| 395 |
|
| 396 |
-
# Halo optioneel
|
| 397 |
if draw_halos:
|
| 398 |
-
for p,
|
| 399 |
-
ax.scatter([p[0]],
|
| 400 |
-
ax.scatter([p[0]],
|
| 401 |
-
|
| 402 |
-
ax.grid(alpha=0.10 if performance_mode else 0.12)
|
| 403 |
-
ax.set_xticks([]); ax.set_yticks([])
|
| 404 |
|
| 405 |
-
|
| 406 |
mean_speed = float(np.linalg.norm(velocities, axis=1).mean())
|
| 407 |
pct_panic = 100.0 * (np.array(stresses) >= 0.66).mean()
|
| 408 |
max_density_inv = max(1e-6, np.min([k3_distance(p, positions) for p in positions]))
|
| 409 |
crowd_index = 1.0 / max_density_inv
|
| 410 |
-
|
| 411 |
-
ax.set_title(title, fontsize=11, color="#374151")
|
| 412 |
|
| 413 |
buf = io.BytesIO()
|
| 414 |
plt.tight_layout()
|
|
@@ -419,11 +350,11 @@ def render_frame(
|
|
| 419 |
|
| 420 |
|
| 421 |
# ---------------------------
|
| 422 |
-
#
|
| 423 |
# ---------------------------
|
| 424 |
def simulate_states(n_agents: int, steps: int, world: World, params: dict, layout: str):
|
| 425 |
agents = init_agents(n_agents, world, layout)
|
| 426 |
-
states = []
|
| 427 |
for _ in range(steps):
|
| 428 |
pos = np.array([a.pos.copy() for a in agents])
|
| 429 |
vel = np.array([a.vel.copy() for a in agents])
|
|
@@ -431,146 +362,70 @@ def simulate_states(n_agents: int, steps: int, world: World, params: dict, layou
|
|
| 431 |
social_force_step(agents, world, params, dt=0.12)
|
| 432 |
return states
|
| 433 |
|
| 434 |
-
|
| 435 |
-
def render_from_states(states, idx, world: World, params: dict,
|
| 436 |
show_trails=True, show_heatmap=False, show_risk_tiles=False,
|
| 437 |
-
performance_mode=True):
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
)
|
| 456 |
|
| 457 |
|
| 458 |
# ---------------------------
|
| 459 |
-
# Gradio
|
| 460 |
# ---------------------------
|
| 461 |
def build_world(preset_name: str, obstacles_json: str) -> World:
|
| 462 |
obstacles = list(PRESETS.get(preset_name, [])) + parse_obstacles(obstacles_json)
|
| 463 |
return World(obstacles=obstacles)
|
| 464 |
|
| 465 |
-
def
|
| 466 |
preset: str, obstacles_json: str, n_agents: int, steps: int, layout: str,
|
| 467 |
desired_speed: float, relax_time: float, people_repulsion: float, people_range: float,
|
| 468 |
obstacle_repulsion: float, obstacle_range: float, noise: float, bounce: bool,
|
| 469 |
-
show_trails: bool, show_heatmap: bool, show_risk_tiles: bool,
|
| 470 |
-
|
| 471 |
):
|
| 472 |
world = build_world(preset, obstacles_json)
|
| 473 |
-
params = dict(
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
)
|
| 478 |
-
# Alleen STATES simuleren (snel)
|
| 479 |
states = simulate_states(n_agents, steps, world, params, layout)
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
slider_update = gr.update(value=0, minimum=0, maximum=max(0, len(states)-1), step=1)
|
| 485 |
badge_html = f"<span class='stat-badge'>{len(world.obstacles)} obstakels</span>"
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
world = sim_state["world"]
|
| 498 |
-
params = sim_state["params"]
|
| 499 |
-
layers = sim_state["layers"]
|
| 500 |
-
perf = sim_state["perf"]
|
| 501 |
-
return render_from_states(states, frame_idx, world, params,
|
| 502 |
-
layers["trails"], layers["heatmap"], layers["risk"], perf)
|
| 503 |
-
|
| 504 |
-
def add_obstacle_click(evt: gr.SelectData, world_w: float, world_h: float, start_xy: Optional[Tuple[float, float]], obstacles_json: str):
|
| 505 |
-
if evt is None:
|
| 506 |
-
return obstacles_json, None, gr.Info("Geen klik gedetecteerd.")
|
| 507 |
-
|
| 508 |
-
img_w, img_h = evt.image_size # pixels
|
| 509 |
-
px, py = evt.index # pixels
|
| 510 |
-
wx = (px / max(1, img_w)) * world_w
|
| 511 |
-
wy = (py / max(1, img_h)) * world_h
|
| 512 |
-
|
| 513 |
-
obs = parse_obstacles(obstacles_json)
|
| 514 |
-
|
| 515 |
-
if start_xy is None:
|
| 516 |
-
return obstacles_to_json(obs), (wx, wy), gr.Info(f"Beginpunt obstakel: ({wx:.2f}, {wy:.2f})")
|
| 517 |
-
else:
|
| 518 |
-
x0, y0 = start_xy
|
| 519 |
-
x1, y1 = wx, wy
|
| 520 |
-
x = min(x0, x1)
|
| 521 |
-
y = min(y0, y1)
|
| 522 |
-
w = abs(x1 - x0)
|
| 523 |
-
h = abs(y1 - y0)
|
| 524 |
-
if w < 0.2 or h < 0.2:
|
| 525 |
-
return obstacles_to_json(obs), None, gr.Warning("Obstakel is te klein; minimaal 0.2 × 0.2 aub.")
|
| 526 |
-
obs.append(RectObstacle(x, y, w, h))
|
| 527 |
-
return obstacles_to_json(obs), None, gr.Info(f"Obstakel toegevoegd: x={x:.2f}, y={y:.2f}, w={w:.2f}, h={h:.2f}")
|
| 528 |
-
|
| 529 |
-
def undo_obstacle(obstacles_json: str):
|
| 530 |
-
obs = parse_obstacles(obstacles_json)
|
| 531 |
-
if not obs:
|
| 532 |
-
return obstacles_json, gr.Warning("Geen obstakels om te verwijderen.")
|
| 533 |
-
obs.pop()
|
| 534 |
-
return obstacles_to_json(obs), gr.Info("Laatste obstakel verwijderd.")
|
| 535 |
-
|
| 536 |
-
def clear_obstacles():
|
| 537 |
-
return "[]", gr.Info("Alle obstakels gewist.")
|
| 538 |
-
|
| 539 |
-
def refresh_after_obstacle_change(
|
| 540 |
-
preset: str, obstacles_json: str, n_agents: int, steps: int, layout: str,
|
| 541 |
-
desired_speed: float, relax_time: float, people_repulsion: float, people_range: float,
|
| 542 |
-
obstacle_repulsion: float, obstacle_range: float, noise: float, bounce: bool,
|
| 543 |
-
show_trails: bool, show_heatmap: bool, show_risk_tiles: bool,
|
| 544 |
-
performance_mode: bool,
|
| 545 |
-
):
|
| 546 |
-
img, sim_state, slider_update, _badge = run_sim(
|
| 547 |
-
preset, obstacles_json, n_agents, steps, layout,
|
| 548 |
-
desired_speed, relax_time, people_repulsion, people_range,
|
| 549 |
-
obstacle_repulsion, obstacle_range, noise, bounce,
|
| 550 |
-
show_trails, show_heatmap, show_risk_tiles,
|
| 551 |
-
performance_mode,
|
| 552 |
-
)
|
| 553 |
-
return img, sim_state, slider_update, f"<span class='stat-badge'>{len(parse_obstacles(obstacles_json))} obstakels</span>"
|
| 554 |
-
|
| 555 |
-
# AUTOPLAY tick: verhoog index en render als 'playing' aan staat
|
| 556 |
-
def tick_play(sim_state: dict, frame_idx: int, playing: bool):
|
| 557 |
-
if not playing or not sim_state:
|
| 558 |
-
return gr.update(), gr.update()
|
| 559 |
-
states = sim_state["states"]
|
| 560 |
-
if not states:
|
| 561 |
-
return gr.update(), gr.update()
|
| 562 |
-
new_idx = (frame_idx + 1) % len(states)
|
| 563 |
-
world = sim_state["world"]; params = sim_state["params"]
|
| 564 |
-
layers = sim_state["layers"]; perf = sim_state["perf"]
|
| 565 |
-
img = render_from_states(states, new_idx, world, params,
|
| 566 |
-
layers["trails"], layers["heatmap"], layers["risk"], perf)
|
| 567 |
-
return img, gr.update(value=new_idx)
|
| 568 |
|
| 569 |
|
| 570 |
# ---------------------------
|
| 571 |
# UI
|
| 572 |
# ---------------------------
|
| 573 |
-
with gr.Blocks(theme=THEME, css=CUSTOM_CSS, title="Crowd Behavior Lab
|
| 574 |
gr.Markdown("""
|
| 575 |
<div id='title' class='card' style='margin-bottom:12px'>
|
| 576 |
<h1>👥 Crowd Behavior Lab</h1>
|
|
@@ -579,23 +434,22 @@ with gr.Blocks(theme=THEME, css=CUSTOM_CSS, title="Crowd Behavior Lab", fill_hei
|
|
| 579 |
<span class="legend-dot legend-orange" style="margin-left:12px;"></span>Stress
|
| 580 |
<span class="legend-dot legend-red" style="margin-left:12px;"></span>Paniek
|
| 581 |
</div>
|
|
|
|
| 582 |
</div>
|
| 583 |
""")
|
| 584 |
|
| 585 |
with gr.Row(equal_height=False):
|
| 586 |
with gr.Column(scale=1):
|
| 587 |
gr.Markdown("### Scène & parameters", elem_classes=["card"])
|
| 588 |
-
preset = gr.Dropdown(list(PRESETS.keys()), value="
|
| 589 |
-
obstacles_json = gr.Textbox(label="
|
| 590 |
|
| 591 |
-
layout = gr.Radio(
|
| 592 |
-
|
| 593 |
-
value="Twee-richtingen", label="Stroomrichting"
|
| 594 |
-
)
|
| 595 |
|
| 596 |
with gr.Row():
|
| 597 |
-
n_agents = gr.Slider(5, 200, value=
|
| 598 |
-
steps = gr.Slider(30, 500, value=
|
| 599 |
|
| 600 |
with gr.Accordion("Krachten & gedrag", open=False):
|
| 601 |
desired_speed = gr.Slider(0.5, 2.5, value=1.3, step=0.05, label="Gewenste snelheid (m/s)")
|
|
@@ -607,141 +461,42 @@ with gr.Blocks(theme=THEME, css=CUSTOM_CSS, title="Crowd Behavior Lab", fill_hei
|
|
| 607 |
noise = gr.Slider(0.0, 0.6, value=0.08, step=0.01, label="Gedragsruis")
|
| 608 |
bounce = gr.Checkbox(value=True, label="Veerkrachtige muren (bounce)")
|
| 609 |
|
| 610 |
-
gr.Markdown("### Visualisatie
|
| 611 |
show_trails = gr.Checkbox(value=True, label="Trails")
|
| 612 |
show_heatmap = gr.Checkbox(value=False, label="Density heatmap")
|
| 613 |
show_risk_tiles = gr.Checkbox(value=False, label="Risicozones (tiles)")
|
| 614 |
-
performance_mode = gr.Checkbox(value=True, label="🔋 Performance-modus
|
| 615 |
-
|
| 616 |
-
run_btn = gr.Button("▶️ Run simulatie", variant="primary")
|
| 617 |
|
| 618 |
-
|
| 619 |
-
undo_btn = gr.Button("↩️ Undo obstakel")
|
| 620 |
-
clear_btn = gr.Button("🧹 Clear obstakels")
|
| 621 |
-
|
| 622 |
-
obstacle_status = gr.Markdown("", elem_classes=["card"])
|
| 623 |
|
| 624 |
-
with gr.Column(scale=2):
|
| 625 |
-
gr.Markdown("### Visualisatie & besturing", elem_classes=["card"])
|
| 626 |
-
canvas = gr.Image(label="Simulatie", format="png", interactive=True)
|
| 627 |
-
|
| 628 |
-
with gr.Row():
|
| 629 |
-
frame_slider = gr.Slider(0, 100, value=0, step=1, label="Tijd")
|
| 630 |
-
playing = gr.Checkbox(value=True, label="▶️ Play")
|
| 631 |
-
|
| 632 |
-
sim_state = gr.State({}) # dict met states/world/params/layers
|
| 633 |
badge = gr.Markdown("<span class='stat-badge'>0 obstakels</span>")
|
| 634 |
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
# Timer voor autoplay (±12 fps -> 0.08s)
|
| 640 |
-
timer = gr.Timer(0.08)
|
| 641 |
-
|
| 642 |
-
# --- AUTOSTART: run sim meteen bij laden van de Space ---
|
| 643 |
-
def _auto_start(
|
| 644 |
-
preset_v=preset.value, obstacles_json_v="[]", n_agents_v=60, steps_v=180, layout_v="Twee-richtingen",
|
| 645 |
-
desired_speed_v=1.3, relax_time_v=0.6, people_repulsion_v=4.0, people_range_v=1.2,
|
| 646 |
-
obstacle_repulsion_v=8.0, obstacle_range_v=1.0, noise_v=0.08, bounce_v=True,
|
| 647 |
-
show_trails_v=True, show_heatmap_v=False, show_risk_tiles_v=False, performance_mode_v=True
|
| 648 |
-
):
|
| 649 |
-
# Let op: demo.load krijgt de actuele widgetwaarden via inputs (hieronder),
|
| 650 |
-
# maar we houden defaults voor lokale run zonder UI.
|
| 651 |
-
return run_sim(
|
| 652 |
-
preset_v, obstacles_json_v, n_agents_v, steps_v, layout_v,
|
| 653 |
-
desired_speed_v, relax_time_v, people_repulsion_v, people_range_v,
|
| 654 |
-
obstacle_repulsion_v, obstacle_range_v, noise_v, bounce_v,
|
| 655 |
-
show_trails_v, show_heatmap_v, show_risk_tiles_v, performance_mode_v
|
| 656 |
-
)
|
| 657 |
|
|
|
|
| 658 |
demo.load(
|
| 659 |
-
fn=
|
| 660 |
-
inputs=[
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
],
|
| 666 |
-
outputs=[canvas, sim_state, frame_slider, badge],
|
| 667 |
)
|
| 668 |
|
| 669 |
-
#
|
| 670 |
run_btn.click(
|
| 671 |
-
fn=
|
| 672 |
-
inputs=[
|
| 673 |
-
preset, obstacles_json, n_agents, steps, layout,
|
| 674 |
-
desired_speed, relax_time, people_repulsion, people_range,
|
| 675 |
-
obstacle_repulsion, obstacle_range, noise, bounce,
|
| 676 |
-
show_trails, show_heatmap, show_risk_tiles, performance_mode
|
| 677 |
-
],
|
| 678 |
-
outputs=[canvas, sim_state, frame_slider, badge]
|
| 679 |
-
)
|
| 680 |
-
|
| 681 |
-
# Scrub (render één frame on-demand)
|
| 682 |
-
frame_slider.release(fn=scrub_frame, inputs=[frame_slider, sim_state], outputs=[canvas])
|
| 683 |
-
|
| 684 |
-
# Autoplay tick (loopt door zolang 'playing' aan staat)
|
| 685 |
-
timer.tick(
|
| 686 |
-
fn=tick_play,
|
| 687 |
-
inputs=[sim_state, frame_slider, playing],
|
| 688 |
-
outputs=[canvas, frame_slider],
|
| 689 |
-
)
|
| 690 |
-
|
| 691 |
-
# Klik om obstakels te tekenen (2-kliks)
|
| 692 |
-
canvas.select(
|
| 693 |
-
fn=add_obstacle_click,
|
| 694 |
-
inputs=[world_w, world_h, start_xy, obstacles_json], # evt komt automatisch als 1e arg
|
| 695 |
-
outputs=[obstacles_json, start_xy, obstacle_status]
|
| 696 |
-
).then(
|
| 697 |
-
fn=refresh_after_obstacle_change,
|
| 698 |
-
inputs=[
|
| 699 |
-
preset, obstacles_json, n_agents, steps, layout,
|
| 700 |
-
desired_speed, relax_time, people_repulsion, people_range,
|
| 701 |
-
obstacle_repulsion, obstacle_range, noise, bounce,
|
| 702 |
-
show_trails, show_heatmap, show_risk_tiles, performance_mode
|
| 703 |
-
],
|
| 704 |
-
outputs=[canvas, sim_state, frame_slider, badge]
|
| 705 |
-
)
|
| 706 |
-
|
| 707 |
-
# Undo/Clear
|
| 708 |
-
undo_btn.click(fn=undo_obstacle, inputs=[obstacles_json], outputs=[obstacles_json, obstacle_status])\
|
| 709 |
-
.then(fn=refresh_after_obstacle_change,
|
| 710 |
-
inputs=[preset, obstacles_json, n_agents, steps, layout,
|
| 711 |
-
desired_speed, relax_time, people_repulsion, people_range,
|
| 712 |
-
obstacle_repulsion, obstacle_range, noise, bounce,
|
| 713 |
-
show_trails, show_heatmap, show_risk_tiles, performance_mode],
|
| 714 |
-
outputs=[canvas, sim_state, frame_slider, badge])
|
| 715 |
-
|
| 716 |
-
clear_btn.click(fn=clear_obstacles, inputs=None, outputs=[obstacles_json, obstacle_status])\
|
| 717 |
-
.then(fn=refresh_after_obstacle_change,
|
| 718 |
-
inputs=[preset, obstacles_json, n_agents, steps, layout,
|
| 719 |
-
desired_speed, relax_time, people_repulsion, people_range,
|
| 720 |
-
obstacle_repulsion, obstacle_range, noise, bounce,
|
| 721 |
-
show_trails, show_heatmap, show_risk_tiles, performance_mode],
|
| 722 |
-
outputs=[canvas, sim_state, frame_slider, badge])
|
| 723 |
-
|
| 724 |
-
# Preset change → laad basisobstakels als er nog niets staat
|
| 725 |
-
def load_preset_obs(preset_name: str, current_json: str):
|
| 726 |
-
base = list(PRESETS.get(preset_name, []))
|
| 727 |
-
if base and (not current_json.strip() or current_json.strip() == "[]"):
|
| 728 |
-
return obstacles_to_json(base), gr.Info("Preset-obstakels geladen.")
|
| 729 |
-
return current_json, gr.Info("Preset gekozen. Huidige obstakels blijven behouden.")
|
| 730 |
-
|
| 731 |
-
preset.change(fn=load_preset_obs, inputs=[preset, obstacles_json], outputs=[obstacles_json, obstacle_status])
|
| 732 |
-
|
| 733 |
-
# Handmatige wijziging in obstakel JSON → resimulate
|
| 734 |
-
obstacles_json.change(
|
| 735 |
-
fn=refresh_after_obstacle_change,
|
| 736 |
inputs=[preset, obstacles_json, n_agents, steps, layout,
|
| 737 |
desired_speed, relax_time, people_repulsion, people_range,
|
| 738 |
obstacle_repulsion, obstacle_range, noise, bounce,
|
| 739 |
-
show_trails, show_heatmap, show_risk_tiles, performance_mode],
|
| 740 |
-
outputs=[canvas,
|
| 741 |
)
|
| 742 |
|
| 743 |
-
|
| 744 |
if __name__ == "__main__":
|
| 745 |
-
#
|
| 746 |
-
demo.queue()
|
| 747 |
demo.launch()
|
|
|
|
| 1 |
+
# app.py — Crowd Behavior Lab (animated GIF, bolletjes-only)
|
| 2 |
+
# ----------------------------------------------------------
|
| 3 |
+
# Focus in deze stap:
|
| 4 |
+
# - Altijd bewegende bolletjes via één geanimeerde GIF (server-side gerenderd).
|
| 5 |
+
# - Autostart bij openen van de Space (demo.load).
|
| 6 |
+
# - Geen pijlen, geen slider/timer — zo vermijden we event-/queue-problemen.
|
| 7 |
#
|
| 8 |
+
# Vereisten (requirements.txt):
|
| 9 |
# gradio>=4.44.0
|
| 10 |
# numpy>=1.24
|
| 11 |
# matplotlib>=3.8
|
|
|
|
| 15 |
from __future__ import annotations
|
| 16 |
|
| 17 |
import io
|
| 18 |
+
import os
|
| 19 |
import json
|
| 20 |
import math
|
| 21 |
+
import tempfile
|
| 22 |
from dataclasses import dataclass, asdict
|
| 23 |
from typing import List, Tuple, Optional, Dict
|
| 24 |
|
|
|
|
| 27 |
import matplotlib
|
| 28 |
matplotlib.use("Agg")
|
| 29 |
import matplotlib.pyplot as plt
|
| 30 |
+
import imageio.v2 as imageio
|
| 31 |
import gradio as gr
|
| 32 |
|
| 33 |
|
| 34 |
# ---------------------------
|
| 35 |
+
# Theme & CSS
|
| 36 |
# ---------------------------
|
| 37 |
THEME = gr.themes.Soft(primary_hue="indigo", secondary_hue="violet")
|
| 38 |
CUSTOM_CSS = """
|
|
|
|
| 41 |
#title h1 { font-weight: 800; letter-spacing: -0.02em; }
|
| 42 |
.card { box-shadow: var(--shadow); border-radius: 18px; padding: 10px; background: white; }
|
| 43 |
.legend-dot { display:inline-block; width:12px; height:12px; border-radius:50%; margin-right:6px; vertical-align:middle; }
|
| 44 |
+
.legend-green { background:#1db954; } .legend-orange { background:#ff9f1a; } .legend-red { background:#e02424; }
|
| 45 |
+
.stat-badge { display:inline-block; padding:6px 10px; background:#f5f5ff; border-radius:12px; margin-right:8px; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.05); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
"""
|
| 47 |
|
| 48 |
|
| 49 |
# ---------------------------
|
| 50 |
+
# Obstakels (rechthoek-basic)
|
| 51 |
# ---------------------------
|
| 52 |
@dataclass
|
| 53 |
class RectObstacle:
|
| 54 |
+
x: float; y: float; w: float; h: float
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
def contains(self, px: float, py: float) -> bool:
|
| 56 |
return self.x <= px <= self.x + self.w and self.y <= py <= self.y + self.h
|
|
|
|
| 57 |
def nearest_point(self, p: np.ndarray) -> np.ndarray:
|
| 58 |
qx = np.clip(p[0], self.x, self.x + self.w)
|
| 59 |
qy = np.clip(p[1], self.y, self.y + self.h)
|
| 60 |
return np.array([qx, qy], dtype=np.float32)
|
|
|
|
| 61 |
def to_json(self) -> Dict:
|
| 62 |
+
d = asdict(self); return d
|
|
|
|
| 63 |
|
| 64 |
@dataclass
|
| 65 |
class World:
|
| 66 |
+
width: float = 20.0; height: float = 12.0; obstacles: List[RectObstacle] = None
|
|
|
|
|
|
|
|
|
|
| 67 |
def __post_init__(self):
|
| 68 |
+
if self.obstacles is None: self.obstacles = []
|
|
|
|
|
|
|
| 69 |
|
| 70 |
@dataclass
|
| 71 |
class Agent:
|
| 72 |
+
pos: np.ndarray; vel: np.ndarray; goal: np.ndarray
|
|
|
|
|
|
|
| 73 |
|
| 74 |
|
| 75 |
# ---------------------------
|
| 76 |
+
# Presets (incl. taps toelopende hals)
|
| 77 |
# ---------------------------
|
| 78 |
def funnel_presets():
|
| 79 |
presets = {}
|
| 80 |
+
# Taps toelopende flessenhals met segmenten
|
|
|
|
| 81 |
obs = []
|
| 82 |
world_w, world_h = 20.0, 12.0
|
| 83 |
+
left_x, right_x = 2.0, 18.0
|
| 84 |
steps = 8
|
| 85 |
top_y0, bot_y0 = 9.5, 2.5
|
| 86 |
top_y1, bot_y1 = 6.8, 5.2
|
|
|
|
| 90 |
t = i / (steps - 1)
|
| 91 |
top_y = (1 - t) * top_y0 + t * top_y1
|
| 92 |
bot_y = (1 - t) * bot_y0 + t * bot_y1
|
| 93 |
+
obs.append(RectObstacle(x0, top_y, x1 - x0, world_h - top_y))
|
| 94 |
+
obs.append(RectObstacle(x0, 0.0, x1 - x0, bot_y))
|
| 95 |
presets["Flessenhals (taps)"] = obs
|
| 96 |
|
| 97 |
# Dubbele funnel
|
|
|
|
| 116 |
obs2.append(RectObstacle(x0, top_y, x1 - x0, 12.0 - top_y))
|
| 117 |
obs2.append(RectObstacle(x0, 0.0, x1 - x0, bot_y))
|
| 118 |
presets["Dubbele funnel (taps)"] = obs2
|
|
|
|
| 119 |
return presets
|
| 120 |
|
| 121 |
PRESETS = {
|
|
|
|
| 127 |
}
|
| 128 |
|
| 129 |
DEFAULT_PARAMS = dict(
|
| 130 |
+
desired_speed=1.3, relax_time=0.6,
|
| 131 |
+
people_repulsion=4.0, people_range=1.2,
|
| 132 |
+
obstacle_repulsion=8.0, obstacle_range=1.0,
|
| 133 |
+
noise=0.08, bounce_walls=True,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
)
|
| 135 |
|
| 136 |
|
|
|
|
| 138 |
# Helpers
|
| 139 |
# ---------------------------
|
| 140 |
def parse_obstacles(json_text: str) -> List[RectObstacle]:
|
| 141 |
+
if not json_text or not json_text.strip(): return []
|
|
|
|
| 142 |
data = json.loads(json_text)
|
| 143 |
return [RectObstacle(float(d["x"]), float(d["y"]), float(d["w"]), float(d["h"])) for d in data]
|
| 144 |
|
| 145 |
def obstacles_to_json(obstacles: List[RectObstacle]) -> str:
|
| 146 |
+
return json.dumps([asdict(ob) for ob in obstacles], indent=2)
|
| 147 |
|
| 148 |
def init_agents(n_agents: int, world: World, layout: str, seed: int = 42) -> List[Agent]:
|
| 149 |
rng = np.random.default_rng(seed)
|
|
|
|
| 166 |
|
| 167 |
|
| 168 |
# ---------------------------
|
| 169 |
+
# Social-force step
|
| 170 |
# ---------------------------
|
| 171 |
def social_force_step(agents: List[Agent], world: World, params: dict, dt: float = 0.12) -> None:
|
| 172 |
positions = np.array([a.pos for a in agents])
|
|
|
|
| 183 |
|
| 184 |
forces = np.zeros_like(positions)
|
| 185 |
|
| 186 |
+
# Driving force
|
| 187 |
goals = np.array([a.goal for a in agents])
|
| 188 |
to_goal = goals - positions
|
| 189 |
dist_goal = np.linalg.norm(to_goal, axis=1) + 1e-6
|
| 190 |
desired_dir = (to_goal.T / dist_goal).T
|
| 191 |
desired_vel = desired_speed * desired_dir
|
| 192 |
+
forces += (desired_vel - velocities) / max(1e-6, relax_time)
|
|
|
|
| 193 |
|
| 194 |
+
# Repulsie mensen
|
| 195 |
for i in range(len(agents)):
|
| 196 |
diff = positions[i] - positions
|
| 197 |
d = np.linalg.norm(diff, axis=1) + 1e-6
|
|
|
|
| 203 |
|
| 204 |
# Repulsie obstakels
|
| 205 |
for i in range(len(agents)):
|
| 206 |
+
p = positions[i]; f = np.zeros(2, dtype=np.float32)
|
|
|
|
| 207 |
for ob in world.obstacles:
|
| 208 |
q = ob.nearest_point(p)
|
| 209 |
diff = p - q
|
|
|
|
| 217 |
# Ruis
|
| 218 |
forces += noise * np.random.randn(*forces.shape)
|
| 219 |
|
| 220 |
+
# Integratie + limiet
|
| 221 |
new_vel = velocities + dt * forces
|
| 222 |
speeds = np.linalg.norm(new_vel, axis=1) + 1e-6
|
| 223 |
+
new_vel = (new_vel.T * np.minimum(1.0, (desired_speed * 1.8) / speeds)).T
|
|
|
|
| 224 |
new_pos = positions + dt * new_vel
|
| 225 |
|
| 226 |
+
# Collisions met obstakels
|
| 227 |
for i in range(len(agents)):
|
| 228 |
for ob in world.obstacles:
|
| 229 |
if ob.contains(new_pos[i, 0], new_pos[i, 1]):
|
| 230 |
q = ob.nearest_point(new_pos[i])
|
| 231 |
dir_ = new_pos[i] - q
|
| 232 |
+
if np.linalg.norm(dir_) < 1e-6: dir_ = np.array([0.5, 0.0], dtype=np.float32)
|
|
|
|
| 233 |
dir_ = dir_ / (np.linalg.norm(dir_) + 1e-6)
|
| 234 |
new_pos[i] = q + 0.06 * dir_
|
| 235 |
|
|
|
|
| 243 |
|
| 244 |
# Commit
|
| 245 |
for i, a in enumerate(agents):
|
| 246 |
+
a.pos = new_pos[i]; a.vel = new_vel[i]
|
|
|
|
| 247 |
|
| 248 |
|
| 249 |
# ---------------------------
|
| 250 |
+
# Visual metrics & kleur
|
| 251 |
# ---------------------------
|
| 252 |
def k3_distance(point: np.ndarray, all_points: np.ndarray) -> float:
|
| 253 |
dists = np.linalg.norm(all_points - point, axis=1)
|
| 254 |
dists = np.sort(dists[dists > 0])
|
| 255 |
+
if len(dists) == 0: return 1.0
|
| 256 |
+
if len(dists) < 3: return dists[-1]
|
|
|
|
|
|
|
| 257 |
return dists[2]
|
| 258 |
|
| 259 |
def min_obstacle_distance(p: np.ndarray, world: World) -> float:
|
| 260 |
+
if not world.obstacles: return 5.0
|
| 261 |
+
return min(np.linalg.norm(p - ob.nearest_point(p)) for ob in world.obstacles)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
|
| 263 |
def stress_score(p: np.ndarray, v: np.ndarray, all_points: np.ndarray, world: World, desired_speed: float) -> float:
|
| 264 |
sv = min(1.0, (np.linalg.norm(v) / max(1e-6, desired_speed)))
|
| 265 |
+
k3 = k3_distance(p, all_points); sd = np.clip((1.0 / (k3 + 1e-3)) / 2.0, 0.0, 1.0)
|
| 266 |
+
so = math.exp(-min_obstacle_distance(p, world) / 1.2)
|
| 267 |
+
return float(np.clip(0.5 * sv + 0.3 * sd + 0.2 * so, 0.0, 1.0))
|
| 268 |
+
|
| 269 |
+
def stress_to_color(s: float) -> str:
|
| 270 |
+
if s < 0.33: return "#1db954"
|
| 271 |
+
elif s < 0.66: return "#ff9f1a"
|
| 272 |
+
return "#e02424"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
|
| 274 |
|
| 275 |
# ---------------------------
|
| 276 |
+
# Rendering (één frame)
|
| 277 |
# ---------------------------
|
| 278 |
+
def render_frame(positions: np.ndarray, velocities: np.ndarray, world: World, params: dict,
|
| 279 |
+
trail_positions: List[np.ndarray], show_trails=True, show_heatmap=False,
|
| 280 |
+
show_risk_tiles=False, performance_mode=True) -> Image.Image:
|
| 281 |
+
dpi = 100 if performance_mode else 120
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
figsize = (10, 5.6) if performance_mode else (11, 6.2)
|
| 283 |
trail_steps = (4 if performance_mode else 8)
|
| 284 |
draw_halos = (False if performance_mode else True)
|
| 285 |
heatmap_grid = (80, 48) if performance_mode else (100, 60)
|
| 286 |
|
| 287 |
fig, ax = plt.subplots(figsize=figsize)
|
| 288 |
+
ax.set_xlim(0, world.width); ax.set_ylim(0, world.height)
|
| 289 |
+
ax.set_aspect("equal"); ax.set_facecolor("#f9fafb")
|
|
|
|
|
|
|
| 290 |
|
| 291 |
# Obstacles
|
| 292 |
for ob in world.obstacles:
|
| 293 |
+
rect = plt.Rectangle((ob.x, ob.y), ob.w, ob.h, facecolor="#6b7280", alpha=0.20, edgecolor="#1f2937", linewidth=1.0)
|
|
|
|
|
|
|
|
|
|
| 294 |
ax.add_patch(rect)
|
| 295 |
|
| 296 |
+
# Heatmap (optioneel)
|
| 297 |
if show_heatmap and len(positions) > 3:
|
| 298 |
gx, gy = np.mgrid[0:world.width:complex(heatmap_grid[0]), 0:world.height:complex(heatmap_grid[1])]
|
| 299 |
grid = np.zeros_like(gx, dtype=np.float32)
|
|
|
|
| 303 |
if 0 <= ix < grid.shape[0] and 0 <= iy < grid.shape[1]:
|
| 304 |
grid[ix, iy] += 1.0
|
| 305 |
grid = (np.roll(grid, 1, 0) + grid + np.roll(grid, -1, 0) + np.roll(grid, 1, 1) + grid + np.roll(grid, -1, 1)) / 6.0
|
| 306 |
+
ax.imshow(grid.T, extent=(0, world.width, 0, world.height), origin="lower", cmap="magma", alpha=0.35, interpolation="bilinear")
|
|
|
|
|
|
|
|
|
|
| 307 |
|
| 308 |
+
# Risico tiles (optioneel)
|
| 309 |
if show_risk_tiles:
|
| 310 |
+
tiles_x, tiles_y = 16, 10
|
| 311 |
+
tx = world.width / tiles_x; ty = world.height / tiles_y
|
|
|
|
| 312 |
for ix in range(tiles_x):
|
| 313 |
for iy in range(tiles_y):
|
| 314 |
+
mask = (positions[:,0]>=ix*tx)&(positions[:,0]<(ix+1)*tx)&(positions[:,1]>=iy*ty)&(positions[:,1]<(iy+1)*ty)
|
| 315 |
+
if np.count_nonzero(mask) >= 5:
|
| 316 |
+
ax.add_patch(plt.Rectangle((ix*tx,iy*ty),tx,ty,facecolor="#ef4444",alpha=0.1,edgecolor=None))
|
| 317 |
+
|
| 318 |
+
# Trails (optioneel)
|
|
|
|
|
|
|
|
|
|
| 319 |
if show_trails and trail_positions:
|
| 320 |
steps = min(trail_steps, len(trail_positions))
|
| 321 |
for t_back in range(steps):
|
| 322 |
alpha = 0.10 + 0.10 * (t_back / steps)
|
| 323 |
pts = trail_positions[-1 - t_back]
|
| 324 |
+
ax.scatter(pts[:,0], pts[:,1], s=14, c="#111827", alpha=alpha, linewidths=0)
|
| 325 |
|
| 326 |
+
# Dots met stress-kleur
|
| 327 |
+
stresses = [stress_score(p, v, positions, world, params.get("desired_speed", 1.3)) for p,v in zip(positions, velocities)]
|
| 328 |
+
sizes = 26 + 46 * np.array(stresses)
|
|
|
|
| 329 |
colors = [stress_to_color(s) for s in stresses]
|
| 330 |
+
ax.scatter(positions[:,0], positions[:,1], s=sizes, c=colors, edgecolors="#111827", linewidths=0.5, zorder=3)
|
|
|
|
| 331 |
|
|
|
|
| 332 |
if draw_halos:
|
| 333 |
+
for p,c in zip(positions,colors):
|
| 334 |
+
ax.scatter([p[0]],[p[1]],s=200,c=c,alpha=0.08,linewidths=0,zorder=2)
|
| 335 |
+
ax.scatter([p[0]],[p[1]],s=120,c=c,alpha=0.08,linewidths=0,zorder=2)
|
|
|
|
|
|
|
|
|
|
| 336 |
|
| 337 |
+
ax.grid(alpha=0.10); ax.set_xticks([]); ax.set_yticks([])
|
| 338 |
mean_speed = float(np.linalg.norm(velocities, axis=1).mean())
|
| 339 |
pct_panic = 100.0 * (np.array(stresses) >= 0.66).mean()
|
| 340 |
max_density_inv = max(1e-6, np.min([k3_distance(p, positions) for p in positions]))
|
| 341 |
crowd_index = 1.0 / max_density_inv
|
| 342 |
+
ax.set_title(f"Snelheid gem.: {mean_speed:.2f} m/s • % paniek: {pct_panic:.0f}% • Dichtheidsindex: {crowd_index:.2f}", fontsize=11, color="#374151")
|
|
|
|
| 343 |
|
| 344 |
buf = io.BytesIO()
|
| 345 |
plt.tight_layout()
|
|
|
|
| 350 |
|
| 351 |
|
| 352 |
# ---------------------------
|
| 353 |
+
# Simulatie → lijst frames → GIF
|
| 354 |
# ---------------------------
|
| 355 |
def simulate_states(n_agents: int, steps: int, world: World, params: dict, layout: str):
|
| 356 |
agents = init_agents(n_agents, world, layout)
|
| 357 |
+
states = []
|
| 358 |
for _ in range(steps):
|
| 359 |
pos = np.array([a.pos.copy() for a in agents])
|
| 360 |
vel = np.array([a.vel.copy() for a in agents])
|
|
|
|
| 362 |
social_force_step(agents, world, params, dt=0.12)
|
| 363 |
return states
|
| 364 |
|
| 365 |
+
def states_to_gif_path(states, world: World, params: dict,
|
|
|
|
| 366 |
show_trails=True, show_heatmap=False, show_risk_tiles=False,
|
| 367 |
+
performance_mode=True, fps: float = 12.0) -> str:
|
| 368 |
+
frames_np = []
|
| 369 |
+
trail_positions: List[np.ndarray] = []
|
| 370 |
+
for i, st in enumerate(states):
|
| 371 |
+
# bouw korte trail
|
| 372 |
+
trail_positions.append(st["pos"])
|
| 373 |
+
if len(trail_positions) > 12: trail_positions.pop(0)
|
| 374 |
+
img = render_frame(st["pos"], st["vel"], world, params, trail_positions,
|
| 375 |
+
show_trails=show_trails, show_heatmap=show_heatmap,
|
| 376 |
+
show_risk_tiles=show_risk_tiles, performance_mode=performance_mode)
|
| 377 |
+
frames_np.append(np.array(img))
|
| 378 |
+
|
| 379 |
+
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".gif")
|
| 380 |
+
tmp_path = tmp.name; tmp.close()
|
| 381 |
+
# duration = seconden per frame
|
| 382 |
+
duration = 1.0 / max(1.0, fps)
|
| 383 |
+
imageio.mimsave(tmp_path, frames_np, format="GIF", duration=duration, loop=0)
|
| 384 |
+
return tmp_path
|
|
|
|
| 385 |
|
| 386 |
|
| 387 |
# ---------------------------
|
| 388 |
+
# Gradio callbacks
|
| 389 |
# ---------------------------
|
| 390 |
def build_world(preset_name: str, obstacles_json: str) -> World:
|
| 391 |
obstacles = list(PRESETS.get(preset_name, [])) + parse_obstacles(obstacles_json)
|
| 392 |
return World(obstacles=obstacles)
|
| 393 |
|
| 394 |
+
def run_sim_to_gif(
|
| 395 |
preset: str, obstacles_json: str, n_agents: int, steps: int, layout: str,
|
| 396 |
desired_speed: float, relax_time: float, people_repulsion: float, people_range: float,
|
| 397 |
obstacle_repulsion: float, obstacle_range: float, noise: float, bounce: bool,
|
| 398 |
+
show_trails: bool, show_heatmap: bool, show_risk_tiles: bool, performance_mode: bool,
|
| 399 |
+
fps: float
|
| 400 |
):
|
| 401 |
world = build_world(preset, obstacles_json)
|
| 402 |
+
params = dict(desired_speed=desired_speed, relax_time=relax_time,
|
| 403 |
+
people_repulsion=people_repulsion, people_range=people_range,
|
| 404 |
+
obstacle_repulsion=obstacle_repulsion, obstacle_range=obstacle_range,
|
| 405 |
+
noise=noise, bounce_walls=bounce)
|
|
|
|
|
|
|
| 406 |
states = simulate_states(n_agents, steps, world, params, layout)
|
| 407 |
+
gif_path = states_to_gif_path(states, world, params,
|
| 408 |
+
show_trails=show_trails, show_heatmap=show_heatmap,
|
| 409 |
+
show_risk_tiles=show_risk_tiles, performance_mode=performance_mode,
|
| 410 |
+
fps=fps)
|
|
|
|
| 411 |
badge_html = f"<span class='stat-badge'>{len(world.obstacles)} obstakels</span>"
|
| 412 |
+
return gif_path, badge_html
|
| 413 |
+
|
| 414 |
+
# Autostart bij load
|
| 415 |
+
def do_autostart(preset, obstacles_json, n_agents, steps, layout,
|
| 416 |
+
desired_speed, relax_time, people_repulsion, people_range,
|
| 417 |
+
obstacle_repulsion, obstacle_range, noise, bounce,
|
| 418 |
+
show_trails, show_heatmap, show_risk_tiles, performance_mode, fps):
|
| 419 |
+
return run_sim_to_gif(preset, obstacles_json, n_agents, steps, layout,
|
| 420 |
+
desired_speed, relax_time, people_repulsion, people_range,
|
| 421 |
+
obstacle_repulsion, obstacle_range, noise, bounce,
|
| 422 |
+
show_trails, show_heatmap, show_risk_tiles, performance_mode, fps)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
|
| 424 |
|
| 425 |
# ---------------------------
|
| 426 |
# UI
|
| 427 |
# ---------------------------
|
| 428 |
+
with gr.Blocks(theme=THEME, css=CUSTOM_CSS, title="Crowd Behavior Lab — GIF") as demo:
|
| 429 |
gr.Markdown("""
|
| 430 |
<div id='title' class='card' style='margin-bottom:12px'>
|
| 431 |
<h1>👥 Crowd Behavior Lab</h1>
|
|
|
|
| 434 |
<span class="legend-dot legend-orange" style="margin-left:12px;"></span>Stress
|
| 435 |
<span class="legend-dot legend-red" style="margin-left:12px;"></span>Paniek
|
| 436 |
</div>
|
| 437 |
+
<p style="margin-top:6px">Deze versie rendert een geanimeerde GIF voor gegarandeerde beweging.</p>
|
| 438 |
</div>
|
| 439 |
""")
|
| 440 |
|
| 441 |
with gr.Row(equal_height=False):
|
| 442 |
with gr.Column(scale=1):
|
| 443 |
gr.Markdown("### Scène & parameters", elem_classes=["card"])
|
| 444 |
+
preset = gr.Dropdown(list(PRESETS.keys()), value="Flessenhals (taps)", label="Obstakelpreset")
|
| 445 |
+
obstacles_json = gr.Textbox(label="Extra obstakels (JSON)", value="[]", lines=4)
|
| 446 |
|
| 447 |
+
layout = gr.Radio(["Links→Rechts","Rechts→Links","Twee-richtingen","Willekeurig"],
|
| 448 |
+
value="Twee-richtingen", label="Stroomrichting")
|
|
|
|
|
|
|
| 449 |
|
| 450 |
with gr.Row():
|
| 451 |
+
n_agents = gr.Slider(5, 200, value=80, step=1, label="Aantal agenten")
|
| 452 |
+
steps = gr.Slider(30, 500, value=200, step=10, label="Simulatiestappen")
|
| 453 |
|
| 454 |
with gr.Accordion("Krachten & gedrag", open=False):
|
| 455 |
desired_speed = gr.Slider(0.5, 2.5, value=1.3, step=0.05, label="Gewenste snelheid (m/s)")
|
|
|
|
| 461 |
noise = gr.Slider(0.0, 0.6, value=0.08, step=0.01, label="Gedragsruis")
|
| 462 |
bounce = gr.Checkbox(value=True, label="Veerkrachtige muren (bounce)")
|
| 463 |
|
| 464 |
+
gr.Markdown("### Visualisatie", elem_classes=["card"])
|
| 465 |
show_trails = gr.Checkbox(value=True, label="Trails")
|
| 466 |
show_heatmap = gr.Checkbox(value=False, label="Density heatmap")
|
| 467 |
show_risk_tiles = gr.Checkbox(value=False, label="Risicozones (tiles)")
|
| 468 |
+
performance_mode = gr.Checkbox(value=True, label="🔋 Performance-modus")
|
| 469 |
+
fps = gr.Slider(5, 30, value=12, step=1, label="GIF FPS (frames/seconde)")
|
|
|
|
| 470 |
|
| 471 |
+
run_btn = gr.Button("▶️ Genereer animatie", variant="primary")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
badge = gr.Markdown("<span class='stat-badge'>0 obstakels</span>")
|
| 474 |
|
| 475 |
+
with gr.Column(scale=2):
|
| 476 |
+
gr.Markdown("### Geanimeerde simulatie", elem_classes=["card"])
|
| 477 |
+
# Belangrijk: GIF tonen als filepath
|
| 478 |
+
canvas = gr.Image(label="Simulatie (GIF)", format="png", interactive=False) # format negeren; pad eindigt op .gif
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
|
| 480 |
+
# Autostart bij openen
|
| 481 |
demo.load(
|
| 482 |
+
fn=do_autostart,
|
| 483 |
+
inputs=[preset, obstacles_json, n_agents, steps, layout,
|
| 484 |
+
desired_speed, relax_time, people_repulsion, people_range,
|
| 485 |
+
obstacle_repulsion, obstacle_range, noise, bounce,
|
| 486 |
+
show_trails, show_heatmap, show_risk_tiles, performance_mode, fps],
|
| 487 |
+
outputs=[canvas, badge],
|
|
|
|
|
|
|
| 488 |
)
|
| 489 |
|
| 490 |
+
# Handmatige run
|
| 491 |
run_btn.click(
|
| 492 |
+
fn=run_sim_to_gif,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
inputs=[preset, obstacles_json, n_agents, steps, layout,
|
| 494 |
desired_speed, relax_time, people_repulsion, people_range,
|
| 495 |
obstacle_repulsion, obstacle_range, noise, bounce,
|
| 496 |
+
show_trails, show_heatmap, show_risk_tiles, performance_mode, fps],
|
| 497 |
+
outputs=[canvas, badge]
|
| 498 |
)
|
| 499 |
|
|
|
|
| 500 |
if __name__ == "__main__":
|
| 501 |
+
# Geen timer/queue meer nodig voor animatie — GIF speelt in de browser.
|
|
|
|
| 502 |
demo.launch()
|