Marcel0123 commited on
Commit
c5da423
·
verified ·
1 Parent(s): 0fd4f8a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +153 -398
app.py CHANGED
@@ -1,11 +1,11 @@
1
- # app.py — Crowd Behavior Lab (autostart + bewegende bolletjes, rechthoek-obstakels)
2
- # ----------------------------------------------------------------------------------
3
- # Fixes:
4
- # - Autostart via demo.load(...) sim draait meteen bij Space-start
5
- # - Timer + demo.queue() frames blijven doorlopen (bewegende dots)
6
- # - Geen richtingpijlen (alleen bolletjes, optionele trails/heatmap)
7
  #
8
- # Vereist in requirements.txt:
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 (full width, clean)
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; } /* groen */
42
- .legend-orange { background:#ff9f1a; } /* oranje */
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
- # Crowd simulation primitives
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
- return asdict(self)
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 # (2,)
87
- vel: np.ndarray # (2,)
88
- goal: np.ndarray # (2,)
89
 
90
 
91
  # ---------------------------
92
- # Parameters & presets
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; right_x = 18.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)) # bovenwand
111
- obs.append(RectObstacle(x0, 0.0, x1 - x0, bot_y)) # onderwand
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, # m/s
149
- relax_time=0.6, # s
150
- people_repulsion=4.0,
151
- people_range=1.2,
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([ob.to_json() for ob in obstacles], indent=2)
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
- # Physics step (simplified social-force)
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 (naar doelen)
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
- driving = (desired_vel - velocities) / max(1e-6, relax_time)
216
- forces += driving
217
 
218
- # Repulsie tussen mensen
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
- max_speed = desired_speed * 1.8
249
- new_vel = (new_vel.T * np.minimum(1.0, max_speed / speeds)).T
250
  new_pos = positions + dt * new_vel
251
 
252
- # Obstakel-collisions: push out
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: stress & density
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
- return 1.0
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
- return 5.0
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
- sd = 1.0 / (k3 + 1e-3)
302
- sd = np.clip(sd / 2.0, 0.0, 1.0)
303
- d_obs = min_obstacle_distance(p, world)
304
- so = math.exp(-d_obs / 1.2)
305
- stress = np.clip(0.5 * sv + 0.3 * sd + 0.2 * so, 0.0, 1.0)
306
- return float(stress)
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 (on-demand, bolletjes-only)
319
  # ---------------------------
320
- def render_frame(
321
- positions: np.ndarray,
322
- velocities: np.ndarray,
323
- world: World,
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.set_ylim(0, world.height)
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
- # Risicovakken
367
  if show_risk_tiles:
368
- tiles_x, tiles_y = (16, 10) if performance_mode else (20, 12)
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[:, 0] >= ix * tx) & (positions[:, 0] < (ix + 1) * tx) & \
374
- (positions[:, 1] >= iy * ty) & (positions[:, 1] < (iy + 1) * ty)
375
- count = np.count_nonzero(mask)
376
- if count >= (5 if performance_mode else 4):
377
- rect = plt.Rectangle((ix * tx, iy * ty), tx, ty, facecolor="#ef4444", alpha=0.10, edgecolor=None)
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[:, 0], pts[:, 1], s=14, c="#111827", alpha=alpha, linewidths=0)
387
 
388
- # Stress & dots (bolletjes!)
389
- stresses = [stress_score(p, v, positions, world, params.get("desired_speed", 1.3))
390
- for p, v in zip(positions, velocities)]
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[:, 0], positions[:, 1], s=sizes, c=colors, edgecolors="#111827",
394
- linewidths=0.5 if performance_mode else 0.6, zorder=3)
395
 
396
- # Halo optioneel
397
  if draw_halos:
398
- for p, c in zip(positions, colors):
399
- ax.scatter([p[0]], [p[1]], s=200, c=c, alpha=0.08, linewidths=0, zorder=2)
400
- ax.scatter([p[0]], [p[1]], s=120, c=c, alpha=0.08, linewidths=0, zorder=2)
401
-
402
- ax.grid(alpha=0.10 if performance_mode else 0.12)
403
- ax.set_xticks([]); ax.set_yticks([])
404
 
405
- # Stats overlay
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
- title = f"Snelheid gem.: {mean_speed:.2f} m/s % paniek: {pct_panic:.0f}% Dichtheidsindex: {crowd_index:.2f}"
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
- # Simulation (states only)
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 = [] # list of dicts: {"pos": (N,2), "vel": (N,2)}
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
- idx = max(0, min(idx, len(states) - 1))
439
- # trails opbouwen
440
- trail_positions = []
441
- if show_trails:
442
- start = max(0, idx - 12)
443
- for j in range(start, idx + 1):
444
- trail_positions.append(states[j]["pos"])
445
- return render_frame(
446
- positions=states[idx]["pos"],
447
- velocities=states[idx]["vel"],
448
- world=world,
449
- params=params,
450
- trail_positions=trail_positions,
451
- show_trails=show_trails,
452
- show_heatmap=show_heatmap,
453
- show_risk_tiles=show_risk_tiles,
454
- performance_mode=performance_mode,
455
- )
456
 
457
 
458
  # ---------------------------
459
- # Gradio Callbacks
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 run_sim(
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
- performance_mode: bool,
471
  ):
472
  world = build_world(preset, obstacles_json)
473
- params = dict(
474
- desired_speed=desired_speed, relax_time=relax_time, people_repulsion=people_repulsion,
475
- people_range=people_range, obstacle_repulsion=obstacle_repulsion, obstacle_range=obstacle_range,
476
- noise=noise, bounce_walls=bounce
477
- )
478
- # Alleen STATES simuleren (snel)
479
  states = simulate_states(n_agents, steps, world, params, layout)
480
- # Eerste frame renderen on-demand
481
- img0 = render_from_states(states, 0, world, params,
482
- show_trails, show_heatmap, show_risk_tiles,
483
- performance_mode)
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
- sim_state = dict(
487
- states=states, world=world, params=params,
488
- layers=dict(trails=show_trails, heatmap=show_heatmap, risk=show_risk_tiles),
489
- perf=performance_mode,
490
- )
491
- return img0, gr.State(sim_state), slider_update, badge_html
492
-
493
- def scrub_frame(frame_idx: int, sim_state: dict):
494
- if not sim_state:
495
- raise gr.Error("Geen simulatiestaten. Run eerst de simulatie.")
496
- states = sim_state["states"]
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", fill_height=True) as demo:
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="Lege ruimte", label="Obstakelpreset")
589
- obstacles_json = gr.Textbox(label="Obstakels (JSON)", value="[]", lines=4)
590
 
591
- layout = gr.Radio(
592
- ["Links→Rechts", "Rechts→Links", "Twee-richtingen", "Willekeurig"],
593
- value="Twee-richtingen", label="Stroomrichting"
594
- )
595
 
596
  with gr.Row():
597
- n_agents = gr.Slider(5, 200, value=60, step=1, label="Aantal agenten")
598
- steps = gr.Slider(30, 500, value=180, step=10, label="Simulatiestappen")
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 lagen", elem_classes=["card"])
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 (sneller renderen)")
615
-
616
- run_btn = gr.Button("▶️ Run simulatie", variant="primary")
617
 
618
- with gr.Row():
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
- start_xy = gr.State(None) # (x,y) eerste klik
636
- world_w = gr.Number(value=20.0, visible=False)
637
- world_h = gr.Number(value=12.0, visible=False)
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=_auto_start,
660
- inputs=[
661
- preset, obstacles_json, n_agents, steps, layout,
662
- desired_speed, relax_time, people_repulsion, people_range,
663
- obstacle_repulsion, obstacle_range, noise, bounce,
664
- show_trails, show_heatmap, show_risk_tiles, performance_mode
665
- ],
666
- outputs=[canvas, sim_state, frame_slider, badge],
667
  )
668
 
669
- # --- Events ---
670
  run_btn.click(
671
- fn=run_sim,
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, sim_state, frame_slider, badge]
741
  )
742
 
743
-
744
  if __name__ == "__main__":
745
- # Timer/autoplay werkt alleen betrouwbaar met queue aan
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()