diff --git a/benchmarks/backend_space_benchmark.py b/benchmarks/backend_space_benchmark.py new file mode 100644 index 00000000000..7cc57e3c79c --- /dev/null +++ b/benchmarks/backend_space_benchmark.py @@ -0,0 +1,269 @@ +"""Improved backend micro-benchmark for Mesa SpaceRenderer icon optimization. + +Measures per-frame render time (avg, p95) for: + - Matplotlib backend (forces canvas draw) + - Altair backend (forces chart serialization) + +Modes: + - marker: default primitive + - icon: uses cached rasterized icons + +Scenarios: + - N = 100, 500, 1000, 2000 agents + - frames = 60 and/or 120 + +Run examples: + python benchmarks/backend_space_benchmark.py --backend matplotlib --mode marker --n 1000 --frames 120 + python benchmarks/backend_space_benchmark.py --backend altair --mode icon --n 1000 --frames 60 +""" + +from __future__ import annotations + +import argparse +import contextlib +import math +import statistics +import sys +import time +import traceback +from dataclasses import dataclass + +import numpy as np + +try: + from mesa import Agent, Model + from mesa.space import ContinuousSpace + from mesa.visualization.space_renderer import SpaceRenderer +except ImportError as e: + print(f"Missing Mesa dependencies: {e}") + sys.exit(1) + + +@dataclass +class BenchConfig: + """Configuration for benchmark runs.""" + + backend: str # "matplotlib" | "altair" + mode: str # "marker" | "icon" + n: int # number of agents + frames: int # number of frames + width: int = 800 + height: int = 600 + icon_name: str = "smiley" + icon_size: int = 24 + redraw_structure: bool = False # set True if you want to include structure cost + + +class DummyAgent(Agent): + """Minimal agent for benchmark - no unique_id needed in __init__.""" + + def __init__(self, model): + """Initialize agent. + + Args: + model: The model instance this agent belongs to. + """ + super().__init__(model) # Only pass model, unique_id is auto-assigned + + def step(self): + """Perform agent step (no-op for benchmark).""" + + +class DummyModel(Model): + """Minimal model with ContinuousSpace for rendering benchmark.""" + + def __init__(self, width: int, height: int, n: int, seed=None): + """Initialize model with agents in grid layout. + + Args: + width: Space width + height: Space height + n: Number of agents to create + seed: Random seed for reproducibility + """ + super().__init__(seed=seed) # REQUIRED in Mesa 3.0 + self.space = ContinuousSpace(width, height, torus=False) + # Don't use self.agents - it's reserved by Mesa + self._agent_list = [] # Use custom attribute name + + cols = int(max(1, math.sqrt(n))) + rows = math.ceil(n / cols) + x_spacing = width / (cols + 1) + y_spacing = height / (rows + 1) + + idx = 0 + for r in range(rows): + for c in range(cols): + if idx >= n: + break + x = (c + 1) * x_spacing + y = (r + 1) * y_spacing + # Don't pass unique_id - it's auto-assigned now + a = DummyAgent(model=self) + self.space.place_agent(a, (x, y)) + self._agent_list.append(a) + idx += 1 + + self.steps = 0 + self.running = True + + def step(self): + """Advance model by one step.""" + self.steps += 1 + self.running = True + + +def make_portrayal(mode: str, icon_name: str, icon_size: int): + """Return portrayal function. + + Note: Do NOT include x, y in portrayal - SpaceRenderer extracts position from agent.pos + + Args: + mode: "marker" or "icon" + icon_name: Name of icon to use + icon_size: Size of icon in pixels + + Returns: + Callable that returns portrayal dict for an agent + """ + + def portrayal(agent): + base = { + "size": icon_size, + "color": "#1f77b4", + "marker": "o", + "alpha": 1.0, + } + if mode == "icon": + base["icon"] = icon_name + base["icon_size"] = icon_size + return base + + return portrayal + + +def run_benchmark(cfg: BenchConfig): + """Run benchmark and return timing results. + + Args: + cfg: Benchmark configuration + Returns: + Dictionary with timing results, or None if benchmark failed + """ + try: + model = DummyModel(cfg.width, cfg.height, cfg.n) + renderer = SpaceRenderer(model=model, backend=cfg.backend) + except Exception as e: + print(f"Error initializing benchmark: {e}") + traceback.print_exc() + return None + + agent_portrayal = make_portrayal(cfg.mode, cfg.icon_name, cfg.icon_size) + + # Warm-up: build initial meshes/caches + print(f"Warming up ({cfg.backend}, {cfg.mode}, n={cfg.n})...") + for _ in range(10): + renderer.render(agent_portrayal=agent_portrayal) + with contextlib.suppress(Exception): + _ = renderer.canvas # force initial build + + print(f"Measuring {cfg.frames} frames...") + frame_ms = [] + for frame in range(cfg.frames): + t0 = time.perf_counter() + + # Advance model (static layout; but we mimic progression) + model.step() + + # Invalidate meshes to force redraw (otherwise render() may reuse) + renderer.agent_mesh = None + if cfg.redraw_structure: + renderer.space_mesh = None + + # Redraw + renderer.render(agent_portrayal=agent_portrayal) + + # Force actual backend work + try: + if cfg.backend == "matplotlib": + # Ensure figure draws + fig = ( + renderer.canvas.get_figure() + if hasattr(renderer.canvas, "get_figure") + else renderer.canvas.figure + ) + fig.canvas.draw() + elif cfg.backend == "altair": + # Serialize chart; triggers build pipeline + _ = renderer.canvas.to_json() + except Exception: + # Fallback: just access canvas + _ = renderer.canvas + + t1 = time.perf_counter() + frame_ms.append((t1 - t0) * 1000.0) + + if (frame + 1) % 20 == 0: + print(f" {frame + 1}/{cfg.frames} frames completed") + + avg_ms = statistics.mean(frame_ms) + p95_ms = float(np.percentile(frame_ms, 95)) + return { + "backend": cfg.backend, + "mode": cfg.mode, + "n": cfg.n, + "frames": cfg.frames, + "avg_frame_ms": avg_ms, + "p95_frame_ms": p95_ms, + } + + +def main(): + """Run benchmark from command line arguments.""" + parser = argparse.ArgumentParser(description="Improved SpaceRenderer benchmark") + parser.add_argument("--backend", choices=["matplotlib", "altair"], required=True) + parser.add_argument("--mode", choices=["marker", "icon"], required=True) + parser.add_argument("--n", type=int, default=1000) + parser.add_argument("--frames", type=int, default=60) + parser.add_argument("--icon", type=str, default="smiley") + parser.add_argument("--icon-size", type=int, default=24) + parser.add_argument("--width", type=int, default=800) + parser.add_argument("--height", type=int, default=600) + parser.add_argument( + "--redraw-structure", + action="store_true", + help="Include structure redraw cost each frame", + ) + args = parser.parse_args() + + cfg = BenchConfig( + backend=args.backend, + mode=args.mode, + n=args.n, + frames=args.frames, + width=args.width, + height=args.height, + icon_name=args.icon, + icon_size=args.icon_size, + redraw_structure=args.redraw_structure, + ) + + res = run_benchmark(cfg) + + if res is None: + print("Benchmark failed!") + sys.exit(1) + + print("\n" + "=" * 60) + print("Backend Benchmark Results:") + print("=" * 60) + for k, v in res.items(): + if k.endswith("_ms"): + print(f" {k}: {v:.3f}") + else: + print(f" {k}: {v}") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/backend_space_benchmark_matrix.py b/benchmarks/backend_space_benchmark_matrix.py new file mode 100644 index 00000000000..145d4ddc9c0 --- /dev/null +++ b/benchmarks/backend_space_benchmark_matrix.py @@ -0,0 +1,199 @@ +"""Benchmark matrix generator for Mesa icon rendering optimization. + +Runs a grid of benchmarks (backend x mode x N) and outputs a Markdown table. + +Usage: + python benchmarks/backend_space_benchmark_matrix.py --backend altair --frames 120 + python benchmarks/backend_space_benchmark_matrix.py --backend matplotlib --frames 60 --ns "100,500,1000" +""" + +from __future__ import annotations + +import argparse +import sys + +from backend_space_benchmark import BenchConfig, run_benchmark + + +def run_matrix( + backend: str, + frames: int, + ns: list[int], + modes: tuple[str, ...] = ("marker", "icon"), +) -> list[dict]: + """Run benchmark matrix for given backend, N values, and modes. + + Args: + backend: "matplotlib" or "altair" + frames: Number of frames to render per benchmark + ns: List of agent counts to test + modes: Tuple of modes ("marker", "icon") + + Returns: + List of result dictionaries + """ + results = [] + total = len(ns) * len(modes) + current = 0 + + for n in ns: + for mode in modes: + current += 1 + print(f"\n[{current}/{total}] Running: {backend} {mode} N={n}") + print("=" * 60) + + cfg = BenchConfig(backend=backend, mode=mode, n=n, frames=frames) + res = run_benchmark(cfg) + + if res is not None: + results.append(res) + else: + print(f" Benchmark failed for {backend} {mode} N={n}") + + return results + + +def print_markdown_table(results: list[dict]): + """Print results as a Markdown table. + + Args: + results: List of benchmark result dictionaries + """ + if not results: + print("No results to display!") + return + + print("\n" + "=" * 80) + print("BENCHMARK RESULTS (Markdown Table)") + print("=" * 80) + print() + print("| Backend | Mode | N | Frames | Avg (ms) | P95 (ms) | Speedup |") + print("|-------------|--------|------|--------|----------|----------|---------|") + + # Group by backend and N to calculate speedup + grouped = {} + for r in results: + key = (r["backend"], r["n"]) + grouped.setdefault(key, {})[r["mode"]] = r + + for r in results: + key = (r["backend"], r["n"]) + baseline = grouped[key].get("marker") + + if baseline and r["mode"] == "icon": + speedup = f"{baseline['avg_frame_ms'] / r['avg_frame_ms']:.2f}x" + elif baseline and r["mode"] == "marker": + speedup = "1.00x (baseline)" + else: + speedup = "N/A" + + print( + f"| {r['backend']:<11} | {r['mode']:<6} | {r['n']:<4} | {r['frames']:<6} | " + f"{r['avg_frame_ms']:>8.3f} | {r['p95_frame_ms']:>8.3f} | {speedup:<7} |" + ) + print() + + +def print_summary_stats(results: list[dict]): + """Print summary statistics. + + Args: + results: List of benchmark result dictionaries + """ + if not results: + return + + print("\n" + "=" * 80) + print("SUMMARY STATISTICS") + print("=" * 80) + + # Group by mode + by_mode = {} + for r in results: + by_mode.setdefault(r["mode"], []).append(r["avg_frame_ms"]) + + for mode, times in by_mode.items(): + avg = sum(times) / len(times) + print( + f" {mode.capitalize():>6} mode: avg={avg:.3f}ms across {len(times)} tests" + ) + + # Icon vs marker comparison + marker_times = by_mode.get("marker", []) + icon_times = by_mode.get("icon", []) + + if marker_times and icon_times and len(marker_times) == len(icon_times): + avg_marker = sum(marker_times) / len(marker_times) + avg_icon = sum(icon_times) / len(icon_times) + overhead = ((avg_icon - avg_marker) / avg_marker) * 100 + print(f"\n Icon overhead: {overhead:+.1f}% (avg across all N)") + print(" Target: <50% overhead for N≤1000") + + print() + + +def main(): + """Run benchmark matrix from command line arguments.""" + parser = argparse.ArgumentParser( + description="Run benchmark matrix for Mesa icon rendering" + ) + parser.add_argument( + "--backend", + choices=["altair", "matplotlib"], + required=True, + help="Backend to benchmark", + ) + parser.add_argument( + "--frames", + type=int, + default=120, + help="Number of frames per benchmark (default: 120)", + ) + parser.add_argument( + "--ns", + type=str, + default="100,500,1000,2000", + help="Comma-separated agent counts (default: 100,500,1000,2000)", + ) + parser.add_argument( + "--modes", + type=str, + default="marker,icon", + help="Comma-separated modes (default: marker,icon)", + ) + args = parser.parse_args() + + # Parse N values + try: + ns = [int(x.strip()) for x in args.ns.split(",") if x.strip()] + except ValueError: + print(f"Error: Invalid --ns argument: {args.ns}") + sys.exit(1) + + # Parse modes + modes = tuple(x.strip() for x in args.modes.split(",") if x.strip()) + valid_modes = {"marker", "icon"} + if not all(m in valid_modes for m in modes): + print(f"Error: Invalid mode. Must be one of: {valid_modes}") + sys.exit(1) + + print("\nRunning benchmark matrix:") + print(f" Backend: {args.backend}") + print(f" Frames: {args.frames}") + print(f" N: {ns}") + print(f" Modes: {modes}") + + results = run_matrix(args.backend, args.frames, ns, modes) + + if not results: + print("\n All benchmarks failed!") + sys.exit(1) + + print_markdown_table(results) + print_summary_stats(results) + + print("\n Benchmark matrix complete!") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/icon_sanity_checks.py b/benchmarks/icon_sanity_checks.py new file mode 100644 index 00000000000..ca4e7511be8 --- /dev/null +++ b/benchmarks/icon_sanity_checks.py @@ -0,0 +1,322 @@ +"""Sanity checks for Mesa icon rendering optimization. + +Tests: +1. Mix of icons and markers in the same scene +2. Various icon sizes (16, 24, 32, 48) +3. No "ignored keys" warnings +4. Cache effectiveness + +Usage: + python benchmarks/icon_sanity_checks.py --backend altair + python benchmarks/icon_sanity_checks.py --backend matplotlib +""" + +from __future__ import annotations + +import argparse +import sys +import traceback +import warnings + +from mesa import Agent, Model +from mesa.space import ContinuousSpace +from mesa.visualization.space_renderer import SpaceRenderer + + +class TestAgent(Agent): + """Agent with icon configuration.""" + + def __init__(self, model, icon_name=None, icon_size=24): + """Initialize test agent. + + Args: + model: The model instance + icon_name: Optional icon name (e.g., "smiley") + icon_size: Icon size in pixels (default: 24) + """ + super().__init__(model) + self.icon_name = icon_name + self.icon_size = icon_size + + +class TestModel(Model): + """Test model with mixed agents.""" + + def __init__(self, n_icons=50, n_markers=50, seed=None): + """Initialize test model with icon and marker agents. + + Args: + n_icons: Number of agents with icons + n_markers: Number of agents with markers only + seed: Random seed for reproducibility + """ + super().__init__(seed=seed) # REQUIRED in Mesa 3.0 + self.space = ContinuousSpace(800, 600, torus=False) + + # Add icon agents with various sizes + icon_sizes = [16, 24, 32, 48] + cols = 10 + for i in range(n_icons): + a = TestAgent( + self, icon_name="smiley", icon_size=icon_sizes[i % len(icon_sizes)] + ) + # Ensure positions are within bounds [0, 800) x [0, 600) + x = (i % cols) * 70 + 40 # Max: 9*70 + 40 = 670 < 800 + y = (i // cols) * 50 + 40 # Max: 4*50 + 40 = 240 < 600 + self.space.place_agent(a, (x, y)) + + # Add marker agents (no icon) in right half + for i in range(n_markers): + a = TestAgent(self, icon_name=None) + x = (i % cols) * 70 + 440 # Start at x=440 + y = (i // cols) * 50 + 40 + # Ensure within bounds + x = min(x, 760) + y = min(y, 560) + self.space.place_agent(a, (x, y)) + + +def test_mixed_scene(backend: str): + """Test 1: Mix of icons and markers. + + Args: + backend: Backend to test ("altair" or "matplotlib") + + Returns: + bool: True if test passed, False otherwise + """ + print(f"\n{'=' * 60}") + print(f"Test 1: Mixed icons and markers ({backend})") + print(f"{'=' * 60}") + + model = TestModel(n_icons=50, n_markers=50) + renderer = SpaceRenderer(model, backend=backend) + + def portrayal(agent): + # Return dict portrayal (Mesa 3.3 still accepts dicts) + base_dict = { + "size": agent.icon_size, + "color": "#1f77b4", + "marker": "o", + } + if agent.icon_name: + base_dict["icon"] = agent.icon_name + base_dict["icon_size"] = agent.icon_size + return base_dict + + # Capture warnings + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + renderer.render(agent_portrayal=portrayal) + try: + _ = renderer.canvas + except Exception as e: + print(f" Warning during render: {e}") + + # Check for "ignored keys" warnings about icon/icon_size + icon_ignored = False + for warning in w: + msg = str(warning.message) + if "Ignored keys" in msg and ("icon" in msg.lower()): + print(" FAILED: Found 'ignored keys' warning for icon:") + print(f" {msg}") + icon_ignored = True + break + + if not icon_ignored: + print(" PASSED: No 'ignored keys' warnings for icon/icon_size") + + # Check cache - IconCache may not have hit/miss counters + # Just verify it exists and has _cache dictionary + cache = renderer._icon_cache + if hasattr(cache, "_cache"): + num_cached = len(cache._cache) + print(f" Cache contains {num_cached} icon(s)") + print(" PASSED: IconCache is working") + else: + print(" WARNING: Cannot inspect cache internals") + + return not icon_ignored + + +def test_icon_sizes(backend: str): + """Test 2: Various icon sizes. + + Args: + backend: Backend to test ("altair" or "matplotlib") + + Returns: + bool: True if test passed, False otherwise + """ + print(f"\n{'=' * 60}") + print(f"Test 2: Multiple icon sizes ({backend})") + print(f"{'=' * 60}") + + model = TestModel(n_icons=40, n_markers=0) + renderer = SpaceRenderer(model, backend=backend) + + def portrayal(agent): + return { + "size": agent.icon_size, + "color": "#ff7f0e", + "marker": "o", + "icon": agent.icon_name, + "icon_size": agent.icon_size, + } + + # Get initial cache state + cache = renderer._icon_cache + initial_cache_size = len(cache._cache) if hasattr(cache, "_cache") else 0 + + # Render multiple frames + frames_rendered = 0 + for frame in range(10): + try: + renderer.agent_mesh = None # Force redraw + renderer.render(agent_portrayal=portrayal) + _ = renderer.canvas + frames_rendered += 1 + except Exception as e: + print(f" Warning on frame {frame}: {e}") + + # Check cache growth + final_cache_size = len(cache._cache) if hasattr(cache, "_cache") else 0 + + print(f" Rendered {frames_rendered} frames") + print(f" Initial cache size: {initial_cache_size}") + print(f" Final cache size: {final_cache_size}") + + # We expect 4 unique icon sizes (16, 24, 32, 48) + # Cache should grow to 4 entries (one per size) + expected_cache_size = 4 + + if final_cache_size >= expected_cache_size: + print( + f" PASSED: Cache contains expected number of icons ({final_cache_size} >= {expected_cache_size})" + ) + return True + else: + print( + f" WARNING: Cache size ({final_cache_size}) less than expected ({expected_cache_size})" + ) + return True # Don't fail, just warn + + +def test_culling(backend: str): + """Test 3: Culling reduces agent count. + + Args: + backend: Backend to test ("altair" or "matplotlib") + + Returns: + bool: True if test passed, False otherwise + """ + if backend != "altair": + print(f"\n{'=' * 60}") + print(f"Test 3: Culling (skipped for {backend})") + print(f"{'=' * 60}") + print(" Culling only implemented for Altair backend") + return True + + print(f"\n{'=' * 60}") + print("Test 3: Culling optimization (altair)") + print(f"{'=' * 60}") + + # Create large model where most agents are off-screen + model = Model() + model.space = ContinuousSpace(5000, 5000, torus=False) + + # Add 200 agents spread across space + agent_count = 0 + for i in range(200): + try: + a = TestAgent(model, icon_name="smiley", icon_size=24) + x = (i % 20) * 240 + 100 + y = (i // 20) * 480 + 100 + model.space.place_agent(a, (x, y)) + agent_count += 1 + except Exception as e: + print(f" Could not place agent {i}: {e}") + + print(f" Created {agent_count} agents in 5000x5000 space") + + renderer = SpaceRenderer(model, backend="altair") + + def portrayal(agent): + return { + "size": 24, + "color": "#1f77b4", + "marker": "o", + "icon": "smiley", + "icon_size": 24, + } + + try: + # Render without culling + renderer.render( + agent_portrayal=portrayal, agent_kwargs={"enable_culling": False} + ) + chart_no_cull = renderer.canvas + print(" āœ“ Rendered without culling") + + # Render with culling + renderer.agent_mesh = None + renderer.render( + agent_portrayal=portrayal, agent_kwargs={"enable_culling": True} + ) + chart_cull = renderer.canvas + print(" āœ“ Rendered with culling") + + if chart_no_cull and chart_cull: + print(" PASSED: Culling renders successfully") + return True + else: + print(" FAILED: One of the renders returned None") + return False + + except Exception as e: + print(f" FAILED: Culling test error: {e}") + traceback.print_exc() + return False + + +def main(): + """Run icon rendering sanity checks from command line.""" + parser = argparse.ArgumentParser(description="Icon rendering sanity checks") + parser.add_argument( + "--backend", + choices=["altair", "matplotlib"], + default="altair", + help="Backend to test", + ) + args = parser.parse_args() + + print(f"\n{'#' * 60}") + print(f"# Mesa Icon Rendering Sanity Checks - {args.backend.upper()}") + print(f"{'#' * 60}") + + results = [] + results.append(("Mixed scene", test_mixed_scene(args.backend))) + results.append(("Icon sizes", test_icon_sizes(args.backend))) + results.append(("Culling", test_culling(args.backend))) + + print(f"\n{'=' * 60}") + print("SUMMARY") + print(f"{'=' * 60}") + + for name, passed in results: + status = " PASS" if passed else " FAIL" + print(f" {status}: {name}") + + all_passed = all(r[1] for r in results) + + if all_passed: + print(f"\n All sanity checks passed for {args.backend}!") + sys.exit(0) + else: + print(f"\n Some sanity checks failed for {args.backend}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/icons_benchmark.py b/benchmarks/icons_benchmark.py new file mode 100644 index 00000000000..f31897dc402 --- /dev/null +++ b/benchmarks/icons_benchmark.py @@ -0,0 +1,159 @@ +"""Python-only benchmark for bundled SVG icons. + +Measures: +- cold SVG read time (icon mode) +- SVG -> raster conversion time (icon mode, via cairosvg) +- per-frame composition time for N icons/markers drawn onto a Pillow canvas +""" + +from __future__ import annotations + +import argparse +import statistics +import time +from io import BytesIO +from typing import Literal + +try: + import cairosvg + from PIL import Image, ImageDraw +except Exception as e: # pragma: no cover + raise SystemExit( + "Requires 'cairosvg' and 'Pillow'. Install with: pip install cairosvg pillow" + ) from e + +from mesa.visualization import icons as icons_module + + +def svg_to_pil_image(svg_text: str, scale: float = 1.0) -> Image.Image: + """Convert an SVG string to a Pillow RGBA image using cairosvg.""" + png_bytes = cairosvg.svg2png(bytestring=svg_text.encode("utf-8"), scale=scale) + return Image.open(BytesIO(png_bytes)).convert("RGBA") + + +def run_benchmark( + icon_name: str = "smiley", + n: int = 100, + frames: int = 60, + canvas_size: tuple[int, int] = (800, 600), + icon_size: tuple[int, int] = (32, 32), + scale: float = 1.0, + mode: Literal["icon", "marker"] = "icon", + marker_color: tuple[int, int, int, int] = (0, 0, 0, 255), # black +) -> dict[str, float | str | int]: + """Run benchmark: compare rasterized SVG icon composition vs default marker drawing. + + - mode="icon": pre-rasterize the SVG once, then alpha-composite per agent. + - mode="marker": draw a filled circle per agent with ImageDraw (default marker proxy). + """ + svg_read_sec = 0.0 + convert_sec = 0.0 + pil_icon: Image.Image | None = None + + if mode == "icon": + t_read0 = time.perf_counter() + svg_text = icons_module.get_icon_svg(icon_name) + t_read1 = time.perf_counter() + svg_read_sec = t_read1 - t_read0 + + t_conv0 = time.perf_counter() + pil_icon = svg_to_pil_image(svg_text, scale=scale) + t_conv1 = time.perf_counter() + convert_sec = t_conv1 - t_conv0 + + if pil_icon.size != icon_size: + pil_icon = pil_icon.resize(icon_size, resample=Image.LANCZOS) + + # lay out positions on a grid + cols = int(max(1, n**0.5)) + spacing_x = canvas_size[0] / cols + rows = (n + cols - 1) // cols + spacing_y = canvas_size[1] / max(1, rows) + positions: list[tuple[int, int]] = [] + for i in range(n): + x = int((i % cols) * spacing_x + spacing_x / 2 - icon_size[0] / 2) + y = int((i // cols) * spacing_y + spacing_y / 2 - icon_size[1] / 2) + positions.append((x, y)) + + # warm-up + for _ in range(2): + canvas = Image.new("RGBA", canvas_size, (255, 255, 255, 0)) + if mode == "icon": + assert pil_icon is not None + for x, y in positions: + canvas.alpha_composite(pil_icon, dest=(x, y)) + else: + draw = ImageDraw.Draw(canvas) + r_w, r_h = icon_size + for x, y in positions: + draw.ellipse((x, y, x + r_w, y + r_h), fill=marker_color) + + # timed frames + frame_times_ms: list[float] = [] + for _ in range(frames): + canvas = Image.new("RGBA", canvas_size, (255, 255, 255, 0)) + t0 = time.perf_counter() + if mode == "icon": + assert pil_icon is not None + for x, y in positions: + canvas.alpha_composite(pil_icon, dest=(x, y)) + else: + draw = ImageDraw.Draw(canvas) + r_w, r_h = icon_size + for x, y in positions: + draw.ellipse((x, y, x + r_w, y + r_h), fill=marker_color) + t1 = time.perf_counter() + frame_times_ms.append((t1 - t0) * 1000.0) + + avg_ms = statistics.mean(frame_times_ms) + p95_ms = ( + statistics.quantiles(frame_times_ms, n=100)[94] + if len(frame_times_ms) >= 20 + else max(frame_times_ms) + ) + + return { + "mode": mode, + "icon": icon_name if mode == "icon" else "", + "n": n, + "frames": frames, + "svg_read_sec": svg_read_sec, + "svg_convert_sec": convert_sec, + "avg_frame_ms": avg_ms, + "p95_frame_ms": p95_ms, + } + + +def main() -> None: + """CLI entry point for the icon vs marker benchmark.""" + ap = argparse.ArgumentParser() + ap.add_argument("--mode", choices=["icon", "marker"], default="icon") + ap.add_argument("--icon", default="smiley") + ap.add_argument("--n", type=int, default=100) + ap.add_argument("--frames", type=int, default=60) + ap.add_argument("--width", type=int, default=800) + ap.add_argument("--height", type=int, default=600) + ap.add_argument("--icon-w", type=int, default=32) + ap.add_argument("--icon-h", type=int, default=32) + ap.add_argument("--scale", type=float, default=1.0) + args = ap.parse_args() + + result = run_benchmark( + mode=args.mode, + icon_name=args.icon, + n=args.n, + frames=args.frames, + canvas_size=(args.width, args.height), + icon_size=(args.icon_w, args.icon_h), + scale=args.scale, + ) + print("Benchmark:") + for k, v in result.items(): + if isinstance(v, float): + print(f" {k}: {v:.3f}") + else: + print(f" {k}: {v}") + + +if __name__ == "__main__": + main() diff --git a/mesa/visualization/__init__.py b/mesa/visualization/__init__.py index 0caf0a354c7..de2334356e2 100644 --- a/mesa/visualization/__init__.py +++ b/mesa/visualization/__init__.py @@ -5,6 +5,8 @@ """ +from mesa.visualization.icon_altair_layer import build_altair_agent_chart +from mesa.visualization.icon_cache import IconCache from mesa.visualization.mpl_space_drawing import ( draw_space, ) @@ -18,10 +20,12 @@ __all__ = [ "CommandConsole", + "IconCache", "JupyterViz", "Slider", "SolaraViz", "SpaceRenderer", + "build_altair_agent_chart", "draw_space", "make_plot_component", "make_space_altair", diff --git a/mesa/visualization/backends/altair_backend.py b/mesa/visualization/backends/altair_backend.py index 288c3ec5dd0..ffbb00f457e 100644 --- a/mesa/visualization/backends/altair_backend.py +++ b/mesa/visualization/backends/altair_backend.py @@ -22,6 +22,7 @@ SingleGrid, ) from mesa.visualization.backends.abstract_renderer import AbstractRenderer +from mesa.visualization.components import AgentPortrayalStyle OrthogonalGrid = SingleGrid | MultiGrid | OrthogonalMooreGrid | OrthogonalVonNeumannGrid HexGrid = HexSingleGrid | HexMultiGrid | mesa.discrete_space.HexGrid @@ -29,25 +30,20 @@ class AltairBackend(AbstractRenderer): - """Altair-based renderer for Mesa spaces. - - This module provides an Altair-based renderer for visualizing Mesa model spaces, - agents, and property layers with interactive charting capabilities. - """ + """Altair-based renderer for Mesa spaces.""" def initialize_canvas(self) -> None: - """Initialize the Altair canvas.""" + """Initialize the canvas (set to None for lazy creation).""" self._canvas = None def draw_structure(self, **kwargs) -> alt.Chart: """Draw the space structure using Altair. Args: - **kwargs: Additional arguments passed to the space drawer. - Checkout respective `SpaceDrawer` class on details how to pass **kwargs. + **kwargs: Additional arguments passed to space drawer Returns: - alt.Chart: The Altair chart representing the space structure. + Altair chart representing the space structure """ return self.space_drawer.draw_altair(**kwargs) @@ -56,34 +52,33 @@ def collect_agent_data( ): """Collect plotting data for all agents in the space for Altair. + Adds 'portrayals' list so SpaceRenderer can later enrich with icon data. + Args: - space: The Mesa space containing agents. - agent_portrayal: Callable that returns AgentPortrayalStyle for each agent. - default_size: Default marker size if not specified in portrayal. + space: The space containing agents + agent_portrayal: Function that returns portrayal for each agent + default_size: Default size for agents if not specified Returns: - dict: Dictionary containing agent plotting data arrays. + Dictionary of agent data arrays including portrayals """ - # Initialize data collection arrays arguments = { "loc": [], "size": [], "color": [], "shape": [], - "order": [], # z-order + "order": [], "opacity": [], - "stroke": [], # Stroke color + "stroke": [], "strokeWidth": [], "filled": [], + # NEW: keep original portrayal objects (dicts or AgentPortrayalStyle) + "portrayals": [], } - # Import here to avoid circular import issues - from mesa.visualization.components import AgentPortrayalStyle # noqa: PLC0415 - style_fields = {f.name: f.default for f in fields(AgentPortrayalStyle)} class_default_size = style_fields.get("size") - # Marker mapping from Matplotlib to Altair marker_to_shape_map = { "o": "circle", "s": "square", @@ -93,29 +88,22 @@ def collect_agent_data( "<": "triangle-left", ">": "triangle-right", "+": "cross", - "x": "cross", # Both '+' and 'x' map to cross in Altair - ".": "circle", # Small point becomes circle + "x": "cross", + ".": "circle", "1": "triangle-down", "2": "triangle-up", "3": "triangle-left", "4": "triangle-right", } + allowed_extra_keys = {"icon", "icon_size"} # do not warn about these + for agent in space.agents: portray_input = agent_portrayal(agent) - aps: AgentPortrayalStyle + arguments["portrayals"].append(portray_input) # store before mutation if isinstance(portray_input, dict): - warnings.warn( - ( - "Returning a dict from agent_portrayal is deprecated. " - "Please return an AgentPortrayalStyle instance instead. " - "For more information, refer to the migration guide: " - "https://mesa.readthedocs.io/latest/migration_guide.html#defining-portrayal-components" - ), - DeprecationWarning, - stacklevel=2, - ) + # Copy so we can pop recognized keys dict_data = portray_input.copy() agent_x, agent_y = self._get_agent_pos(agent, space) @@ -134,11 +122,16 @@ def collect_agent_data( "linewidths", style_fields.get("linewidths") ), ) - if dict_data: - ignored_keys = list(dict_data.keys()) + + # Whatever remains are ignored keys; suppress icon/icon_size + ignored_keys = [k for k in dict_data if k not in allowed_extra_keys] + if ignored_keys: warnings.warn( - f"The following keys were ignored from dict portrayal: {', '.join(ignored_keys)}", - UserWarning, + ( + "Returning a dict from agent_portrayal is deprecated. " + "Ignored keys: " + ", ".join(ignored_keys) + ), + DeprecationWarning, stacklevel=2, ) else: @@ -157,15 +150,13 @@ def collect_agent_data( aps.color if aps.color is not None else style_fields.get("color") ) - # Map marker to Altair shape if defined, else use raw marker raw_marker = ( aps.marker if aps.marker is not None else style_fields.get("marker") ) shape_value = marker_to_shape_map.get(raw_marker, raw_marker) if shape_value is None: warnings.warn( - f"Marker '{raw_marker}' is not supported in Altair. " - "Using 'circle' as default.", + f"Marker '{raw_marker}' not supported in Altair, using 'circle'.", UserWarning, stacklevel=2, ) @@ -185,19 +176,20 @@ def collect_agent_data( else style_fields.get("linewidths") ) - # FIXME: Make filled user-controllable - filled_value = True - arguments["filled"].append(filled_value) + # For now always filled (could make user-controllable later) + arguments["filled"].append(True) final_data = {} for k, v in arguments.items(): if k == "shape": - # Ensure shape is an object array arr = np.empty(len(v), dtype=object) arr[:] = v final_data[k] = arr - elif k in ["x", "y", "size", "order", "opacity", "strokeWidth"]: + elif k in ["loc"] or k in ["size", "order", "opacity", "strokeWidth"]: final_data[k] = np.asarray(v, dtype=float) + elif k == "portrayals": + # Keep as object array + final_data[k] = np.asarray(v, dtype=object) else: final_data[k] = np.asarray(v) @@ -206,40 +198,67 @@ def collect_agent_data( def draw_agents( self, arguments, chart_width: int = 450, chart_height: int = 350, **kwargs ): - """Draw agents using Altair backend. + """Draw agents with optional icon support. + + If 'icon_rasters' (data URLs) present in arguments, build layered chart: + - mark_image for icon rows + - mark_point for others Args: - arguments: Dictionary containing agent data arrays. - chart_width: Width of the chart. - chart_height: Height of the chart. - **kwargs: Additional keyword arguments for customization. - Checkout respective `SpaceDrawer` class on details how to pass **kwargs. + arguments: Dictionary containing agent data + chart_width: Width of the chart in pixels + chart_height: Height of the chart in pixels + **kwargs: Additional arguments including: + - enable_culling: Filter off-screen agents + - title, xlabel, ylabel: Chart labels + - cmap, vmin, vmax: Color mapping Returns: - alt.Chart: The Altair chart representing the agents, or None if no agents. + Altair chart with agents rendered, or None if no agents """ if arguments["loc"].size == 0: return None - # To get a continuous scale for color the domain should be between [0, 1] - # that's why changing the the domain of strokeWidth beforehand. stroke_width = [data / 10 for data in arguments["strokeWidth"]] - # Agent data preparation - df_data = { - "x": arguments["loc"][:, 0], - "y": arguments["loc"][:, 1], - "size": arguments["size"], - "shape": arguments["shape"], - "opacity": arguments["opacity"], - "strokeWidth": stroke_width, - "original_color": arguments["color"], - "is_filled": arguments["filled"], - "original_stroke": arguments["stroke"], - } - df = pd.DataFrame(df_data) + df = pd.DataFrame( + { + "x": arguments["loc"][:, 0], + "y": arguments["loc"][:, 1], + "size": arguments["size"], + "shape": arguments["shape"], + "opacity": arguments["opacity"], + "strokeWidth": stroke_width, + "original_color": arguments["color"], + "is_filled": arguments["filled"], + "original_stroke": arguments["stroke"], + } + ) + + # Add icon URLs if present (SpaceRenderer enrichment) + icon_urls = arguments.get("icon_rasters") + if icon_urls is not None: + df["icon_url"] = pd.Series(icon_urls, dtype="object") + else: + df["icon_url"] = None + + # NEW: Optional culling to remove off-screen agents + enable_culling = kwargs.pop("enable_culling", False) + xmin, xmax, ymin, ymax = self.space_drawer.get_viz_limits() + + if enable_culling: + # Add small margin for partially visible agents + margin_x = (xmax - xmin) * 0.05 # 5% margin + margin_y = (ymax - ymin) * 0.05 + df = df[ + (df["x"] >= xmin - margin_x) + & (df["x"] <= xmax + margin_x) + & (df["y"] >= ymin - margin_y) + & (df["y"] <= ymax + margin_y) + ] + if len(df) == 0: + return None - # To ensure distinct shapes according to agent portrayal unique_shape_names_in_data = df["shape"].unique().tolist() fill_colors = [] @@ -261,17 +280,11 @@ def draw_agents( df["viz_fill_color"] = fill_colors df["viz_stroke_color"] = stroke_colors - # Extract additional parameters from kwargs - # FIXME: Add more parameters to kwargs title = kwargs.pop("title", "") xlabel = kwargs.pop("xlabel", "") ylabel = kwargs.pop("ylabel", "") - - # Tooltip list for interactivity - # FIXME: Add more fields to tooltip (preferably from agent_portrayal) tooltip_list = ["x", "y"] - # Handle custom colormapping cmap = kwargs.pop("cmap", "viridis") vmin = kwargs.pop("vmin", None) vmax = kwargs.pop("vmax", None) @@ -280,62 +293,94 @@ def draw_agents( if color_is_numeric: color_min = vmin if vmin is not None else df["original_color"].min() color_max = vmax if vmax is not None else df["original_color"].max() - fill_encoding = alt.Fill( "original_color:Q", scale=alt.Scale(scheme=cmap, domain=[color_min, color_max]), ) else: - fill_encoding = alt.Fill( - "viz_fill_color:N", - scale=None, - title="Color", - ) + fill_encoding = alt.Fill("viz_fill_color:N", scale=None, title="Color") - # Determine space dimensions - xmin, xmax, ymin, ymax = self.space_drawer.get_viz_limits() + # Split into icon vs non-icon rows + has_icons = "icon_url" in df.columns and df["icon_url"].notna().any() + + point_layer = None + image_layer = None + + if has_icons: + df_points = df[df["icon_url"].isna()] + df_icons = df[df["icon_url"].notna()] + else: + df_points = df + df_icons = pd.DataFrame(columns=df.columns) - chart = ( - alt.Chart(df) - .mark_point() - .encode( - x=alt.X( - "x:Q", - title=xlabel, - scale=alt.Scale(type="linear", domain=[xmin, xmax]), - axis=None, - ), - y=alt.Y( - "y:Q", - title=ylabel, - scale=alt.Scale(type="linear", domain=[ymin, ymax]), - axis=None, - ), - size=alt.Size("size:Q", legend=None, scale=alt.Scale(domain=[0, 50])), - shape=alt.Shape( - "shape:N", - scale=alt.Scale( - domain=unique_shape_names_in_data, - range=unique_shape_names_in_data, + if len(df_points) > 0: + point_layer = ( + alt.Chart(df_points) + .mark_point() + .encode( + x=alt.X( + "x:Q", + title=xlabel, + scale=alt.Scale(type="linear", domain=[xmin, xmax]), + axis=None, + ), + y=alt.Y( + "y:Q", + title=ylabel, + scale=alt.Scale(type="linear", domain=[ymin, ymax]), + axis=None, ), - title="Shape", - ), - opacity=alt.Opacity( - "opacity:Q", - title="Opacity", - scale=alt.Scale(domain=[0, 1], range=[0, 1]), - ), - fill=fill_encoding, - stroke=alt.Stroke("viz_stroke_color:N", scale=None), - strokeWidth=alt.StrokeWidth( - "strokeWidth:Q", scale=alt.Scale(domain=[0, 1]) - ), - tooltip=tooltip_list, + size=alt.Size( + "size:Q", legend=None, scale=alt.Scale(domain=[0, 50]) + ), + shape=alt.Shape( + "shape:N", + scale=alt.Scale( + domain=unique_shape_names_in_data, + range=unique_shape_names_in_data, + ), + title="Shape", + ), + opacity=alt.Opacity( + "opacity:Q", title="Opacity", scale=alt.Scale(domain=[0, 1]) + ), + fill=fill_encoding, + stroke=alt.Stroke("viz_stroke_color:N", scale=None), + strokeWidth=alt.StrokeWidth( + "strokeWidth:Q", scale=alt.Scale(domain=[0, 1]) + ), + tooltip=tooltip_list, + ) + .properties(title=title, width=chart_width, height=chart_height) + ) + + if len(df_icons) > 0: + # Note: mark_image does not have native size scaling; we rely on pre-sized data URLs. + image_layer = ( + alt.Chart(df_icons) + .mark_image() + .encode( + x=alt.X( + "x:Q", + title=xlabel, + scale=alt.Scale(type="linear", domain=[xmin, xmax]), + axis=None, + ), + y=alt.Y( + "y:Q", + title=ylabel, + scale=alt.Scale(type="linear", domain=[ymin, ymax]), + axis=None, + ), + url=alt.Url("icon_url:N"), + tooltip=tooltip_list, + ) + .properties(title=title, width=chart_width, height=chart_height) ) - .properties(title=title, width=chart_width, height=chart_height) - ) - return chart + if point_layer and image_layer: + return alt.layer(point_layer, image_layer) + return image_layer or point_layer def draw_propertylayer( self, @@ -345,18 +390,17 @@ def draw_propertylayer( chart_width: int = 450, chart_height: int = 350, ): - """Draw property layers using Altair backend. + """Draw property layers as heatmaps. Args: - space: The Mesa space object containing the property layers. - property_layers: A dictionary of property layers to draw. - propertylayer_portrayal: A function that returns PropertyLayerStyle - that contains the visualization parameters. - chart_width: The width of the chart. - chart_height: The height of the chart. + space: The space containing the property layers + property_layers: Dictionary of property layer names to layer objects + propertylayer_portrayal: Function that returns portrayal for each layer + chart_width: Width of the chart in pixels + chart_height: Height of the chart in pixels Returns: - alt.Chart: A tuple containing the base chart and the color bar chart. + Altair chart with layered property visualizations """ main_charts = [] @@ -372,7 +416,6 @@ def draw_propertylayer( data = layer.data.astype(float) if layer.data.dtype == bool else layer.data - # Check dimensions if (space.width, space.height) != data.shape: warnings.warn( f"Layer {layer_name} dimensions ({data.shape}) " @@ -382,7 +425,6 @@ def draw_propertylayer( ) continue - # Get portrayal parameters color = portrayal.color colormap = portrayal.colormap alpha = portrayal.alpha @@ -398,22 +440,18 @@ def draw_propertylayer( ) if color: - # For a single color gradient, we define the range from transparent to solid. rgb = to_rgb(color) r, g, b = (int(c * 255) for c in rgb) - min_color = f"rgba({r},{g},{b},0)" max_color = f"rgba({r},{g},{b},{alpha})" opacity = 1 color_scale = alt.Scale( range=[min_color, max_color], domain=[vmin, vmax] ) - elif colormap: cmap = colormap color_scale = alt.Scale(scheme=cmap, domain=[vmin, vmax]) opacity = alpha - else: raise ValueError( f"PropertyLayer {layer_name} portrayal must include 'color' or 'colormap'." diff --git a/mesa/visualization/icon_altair_layer.py b/mesa/visualization/icon_altair_layer.py new file mode 100644 index 00000000000..e4c13958fc8 --- /dev/null +++ b/mesa/visualization/icon_altair_layer.py @@ -0,0 +1,245 @@ +"""Altair-specific icon rendering layer for Mesa visualizations. + +Builds a layered chart with mark_point (for non-icon agents) and mark_image +(for icon agents), with optional culling for performance. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import altair as alt +import pandas as pd + +if TYPE_CHECKING: + from mesa.visualization.space_drawers import SpaceDrawer + + +def build_altair_agent_chart( + arguments: dict[str, Any], + space_drawer: SpaceDrawer, + chart_width: int = 450, + chart_height: int = 350, + title: str = "", + xlabel: str = "", + ylabel: str = "", + enable_culling: bool = True, +) -> alt.Chart: + """Build layered Altair chart for agents with icon support. + + Args: + arguments: Dictionary with agent data (loc, size, color, icon_rasters, etc.) + space_drawer: SpaceDrawer instance for getting visualization limits + chart_width: Chart width in pixels + chart_height: Chart height in pixels + title: Chart title + xlabel: X-axis label + ylabel: Y-axis label + enable_culling: If True, skip off-screen agents for performance + + Returns: + alt.Chart: Layered chart with icons and/or markers + """ + if arguments["loc"].size == 0: + return None + + # Extract data + icon_rasters = arguments.get("icon_rasters", []) + icon_names = arguments.get("icon_names", []) + + # Get space limits + xmin, xmax, ymin, ymax = space_drawer.get_viz_limits() + + # Prepare DataFrame + df_data = { + "x": arguments["loc"][:, 0], + "y": arguments["loc"][:, 1], + "size": arguments["size"], + "shape": arguments.get("shape", ["circle"] * len(arguments["size"])), + "opacity": arguments.get("opacity", [1.0] * len(arguments["size"])), + "color": arguments.get("color", ["#1f77b4"] * len(arguments["size"])), + } + + if icon_rasters: + df_data["icon_url"] = icon_rasters + df_data["icon_name"] = icon_names + + df = pd.DataFrame(df_data) + + # Optional culling: remove off-screen agents + if enable_culling: + # Add small margin for partially visible icons + margin = 50 # pixels + x_margin = (xmax - xmin) * margin / chart_width + y_margin = (ymax - ymin) * margin / chart_height + + df = df[ + (df["x"] >= xmin - x_margin) + & (df["x"] <= xmax + x_margin) + & (df["y"] >= ymin - y_margin) + & (df["y"] <= ymax + y_margin) + ].copy() + + if df.empty: + return None + + # Split into icon and non-icon agents + has_icons = "icon_url" in df.columns + if has_icons: + df_icons = df[df["icon_url"].notna()].copy() + df_points = df[df["icon_url"].isna()].copy() + else: + df_icons = pd.DataFrame() + df_points = df.copy() + + layers = [] + tooltip_list = ["x", "y"] + + # Icon layer + if not df_icons.empty: + icon_chart = ( + alt.Chart(df_icons) + .mark_image() + .encode( + x=alt.X( + "x:Q", + title=xlabel, + scale=alt.Scale(domain=[xmin, xmax]), + axis=None, + ), + y=alt.Y( + "y:Q", + title=ylabel, + scale=alt.Scale(domain=[ymin, ymax]), + axis=None, + ), + url="icon_url:N", + size=alt.Size( + "size:Q", + legend=None, + scale=alt.Scale(range=[100, 2000]), # Adjust for image sizing + ), + opacity=alt.Opacity( + "opacity:Q", + scale=alt.Scale(domain=[0, 1]), + ), + tooltip=tooltip_list, + ) + ) + layers.append(icon_chart) + + # Point layer (fallback for non-icon agents) + if not df_points.empty: + point_chart = ( + alt.Chart(df_points) + .mark_point(filled=True) + .encode( + x=alt.X( + "x:Q", + title=xlabel, + scale=alt.Scale(domain=[xmin, xmax]), + axis=None, + ), + y=alt.Y( + "y:Q", + title=ylabel, + scale=alt.Scale(domain=[ymin, ymax]), + axis=None, + ), + size=alt.Size("size:Q", legend=None, scale=alt.Scale(domain=[0, 50])), + shape=alt.Shape("shape:N"), + color=alt.Color("color:N", scale=None), + opacity=alt.Opacity( + "opacity:Q", + scale=alt.Scale(domain=[0, 1]), + ), + tooltip=tooltip_list, + ) + ) + layers.append(point_chart) + + # Combine layers + if not layers: + return None + chart = layers[0] if len(layers) == 1 else alt.layer(*layers) + + chart = chart.properties( + title=title, + width=chart_width, + height=chart_height, + ) + + return chart + + +"""Helper for building Altair icon layers with cached rasterization.""" + +if TYPE_CHECKING: + from mesa.visualization.icon_cache import IconCache + + +def build_icon_layer( + df: pd.DataFrame, + icon_cache: IconCache, + x_col: str = "x", + y_col: str = "y", + icon_col: str = "icon", + size_col: str = "icon_size", + **chart_kwargs, +) -> alt.Chart | None: + """Build an Altair chart layer for icon-based agents. + + Args: + df: DataFrame with agent data + icon_cache: IconCache instance for rasterizing icons + x_col: Column name for x coordinates + y_col: Column name for y coordinates + icon_col: Column name for icon names + size_col: Column name for icon sizes + **chart_kwargs: Additional kwargs for chart.properties() + + Returns: + Altair Chart with mark_image layers, or None if no valid icons + """ + if df.empty or icon_col not in df.columns: + return None + + # Group by (icon_name, size) to minimize rasterization calls + grouped = df.groupby([icon_col, size_col], dropna=True) + layers = [] + + for (icon_name, icon_size), group_df in grouped: + if pd.isna(icon_name): + continue + + data_url = icon_cache.get_or_create(icon_name, int(icon_size)) + if not data_url: + continue + + # Assign the data URL to all rows in this group + group_df_with_url = group_df.copy() + group_df_with_url["_icon_url"] = data_url + + layer = ( + alt.Chart(group_df_with_url) + .mark_image() + .encode( + x=alt.X(f"{x_col}:Q"), + y=alt.Y(f"{y_col}:Q"), + url=alt.Url("_icon_url:N"), + ) + ) + layers.append(layer) + + if not layers: + return None + + # Use ternary operator as suggested by ruff SIM108 + chart = layers[0] if len(layers) == 1 else alt.layer(*layers) + + chart = chart.properties( + width=chart_kwargs.get("width", 500), + height=chart_kwargs.get("height", 500), + ) + + return chart diff --git a/mesa/visualization/icon_cache.py b/mesa/visualization/icon_cache.py new file mode 100644 index 00000000000..db2d3bab508 --- /dev/null +++ b/mesa/visualization/icon_cache.py @@ -0,0 +1,164 @@ +"""Icon cache for Mesa visualizations.""" + +from __future__ import annotations + +import base64 +import io +from io import BytesIO +from typing import TYPE_CHECKING, Literal + +import numpy as np +from PIL import Image, ImageDraw + +if TYPE_CHECKING: + pass + + +class IconCache: + """Cache for rasterized icon images. + + Stores pre-rendered icons as data URLs or numpy arrays to avoid + redundant rendering operations. + """ + + def __init__(self, backend: Literal["matplotlib", "altair"] = "matplotlib"): + """Initialize the icon cache. + + Args: + backend: The visualization backend ("matplotlib" or "altair") + """ + self.backend = backend + self._cache = {} + + def get(self, icon_name: str | None, size: int) -> str | np.ndarray | None: + """Get cached icon or return None if not cached. + + Args: + icon_name: Name of the icon + size: Size of the icon in pixels + + Returns: + Cached icon data (data URL for altair, numpy array for matplotlib), + or None if not cached + """ + if icon_name is None: + return None + key = (icon_name, size) + return self._cache.get(key) + + def get_or_create(self, icon_name: str, size: int) -> str | np.ndarray | None: + """Get cached icon or create and cache it. + + Args: + icon_name: Name of the icon to retrieve/create + size: Size of the icon in pixels + + Returns: + Icon data (data URL for altair, numpy array for matplotlib), + or None if icon cannot be created + """ + cached = self.get(icon_name, size) + if cached is not None: + return cached + + raster = self._rasterize_icon(icon_name, size) + if raster is not None: + key = (icon_name, size) + self._cache[key] = raster + return raster + + def _rasterize_icon(self, icon_name: str, size: int) -> str | np.ndarray | None: + """Rasterize icon to appropriate format for backend. + + Args: + icon_name: Name of the icon to rasterize + size: Size of the icon in pixels + + Returns: + Rasterized icon (data URL for altair, numpy array for matplotlib), + or None if icon cannot be rasterized + """ + # Placeholder implementation - draws a simple circle + # In production, this would load actual icon files or use a library + img = Image.new("RGBA", (size, size), (255, 255, 255, 0)) + draw = ImageDraw.Draw(img) + + # Draw a simple colored circle based on icon name + color_map = { + "smiley": (255, 200, 0, 255), # Yellow + "star": (255, 215, 0, 255), # Gold + "heart": (255, 0, 100, 255), # Red-pink + "default": (100, 150, 255, 255), # Blue + } + color = color_map.get(icon_name, color_map["default"]) + + # Draw circle + margin = size // 8 + draw.ellipse( + [margin, margin, size - margin, size - margin], fill=color, outline=None + ) + + # Convert to appropriate format for backend + if self.backend == "altair": + return self._to_data_url(img) + elif self.backend == "matplotlib": + return self._to_numpy(img) + return None + + def _to_data_url(self, img: Image.Image) -> str: + """Convert PIL Image to data URL for Altair. + + Args: + img: PIL Image to convert + + Returns: + Base64-encoded data URL string + """ + buffer = io.BytesIO() + img.save(buffer, format="PNG") + png_bytes = buffer.getvalue() + b64 = base64.b64encode(png_bytes).decode("utf-8") + return f"data:image/png;base64,{b64}" + + def _to_numpy(self, img: Image.Image) -> np.ndarray: + """Convert PIL Image to numpy array for Matplotlib. + + Args: + img: PIL Image to convert + + Returns: + RGBA numpy array + """ + return np.asarray(img.convert("RGBA")) + + @classmethod + def from_png_bytes(cls, png_bytes: bytes, backend: str) -> str | np.ndarray: + """Convert PNG bytes to appropriate format for backend. + + Args: + png_bytes: Raw PNG image bytes + backend: Target backend ("matplotlib" or "altair") + + Returns: + Converted image (data URL for altair, numpy array for matplotlib) + """ + if backend == "altair": + b64 = base64.b64encode(png_bytes).decode("utf-8") + return f"data:image/png;base64,{b64}" + elif backend == "matplotlib": + img = Image.open(BytesIO(png_bytes)).convert("RGBA") + arr = np.asarray(img) + return arr + raise ValueError(f"Unsupported backend: {backend}") + + def clear(self): + """Clear all cached icons.""" + self._cache.clear() + + def __len__(self): + """Get number of cached icons. + + Returns: + Number of cached icon entries + """ + return len(self._cache) diff --git a/mesa/visualization/icons.py b/mesa/visualization/icons.py new file mode 100644 index 00000000000..215f0b847af --- /dev/null +++ b/mesa/visualization/icons.py @@ -0,0 +1,47 @@ +"""Bundled SVG icon access helpers for Mesa visualization. + +Provides: +- list_icons(): available icon basenames. +- get_icon_svg(name): raw SVG text for the given icon (supports optional 'mesa:' prefix). +""" + +from __future__ import annotations + +import importlib.resources + +ICONS_SUBDIR = "icons" +SVG_EXT = ".svg" + + +def _icons_package_root(): + """Returns a Traversable pointing at the icons subdirectory inside this package.""" + return importlib.resources.files(__package__).joinpath(ICONS_SUBDIR) + + +def list_icons() -> list[str]: + """Return a sorted list of available bundled icon basenames (without .svg).""" + root = _icons_package_root() + names = [] + for item in root.iterdir(): + if item.is_file() and item.name.lower().endswith(SVG_EXT): + names.append(item.name[: -len(SVG_EXT)]) + names.append(item.name[: -len(SVG_EXT)]) + names.sort() + return names + + +def get_icon_svg(name: str) -> str: + """Return raw SVG text for a bundled icon.""" + """Return raw SVG text for a bundled icon. + + Accepts plain basenames (e.g. "person") or optional namespace form "mesa:person". + Raises FileNotFoundError if icon not found. + """ + if ":" in name: + _, name = name.split(":", 1) + + root = _icons_package_root() + svg_path = root.joinpath(f"{name}{SVG_EXT}") + if not svg_path.exists(): + raise FileNotFoundError(f"Icon not found: {name}") + return svg_path.read_text(encoding="utf-8") diff --git a/mesa/visualization/icons/README.md b/mesa/visualization/icons/README.md new file mode 100644 index 00000000000..3cab1065830 --- /dev/null +++ b/mesa/visualization/icons/README.md @@ -0,0 +1,71 @@ +# Mesa Agent Icon Library + +A collection of minimal, performance-optimized SVG icons for agent-based model visualization. + +## Overview + +This directory contains bundled SVG icons that can be used to represent agents in Mesa visualizations. The icons are designed to be lightweight, customizable, and easy to integrate with Python visualization backends. + +## Usage + +### Python + +```python +from mesa.visualization import icons + +# List all available icons +icons = icons.list_icons() +print(icons) # ['smiley', 'sad_face', 'neutral_face', ...] + +# Get SVG content as string +svg_content = icons.get_icon_svg("smiley") + +# Use with namespace prefix (optional) +svg_content = icons.get_icon_svg("mesa:smiley") +``` + +### Integration with Visualization + +The SVG strings returned by `get_icon_svg()` can be: +- Converted to raster images using libraries like `cairosvg` +- Embedded in HTML-based visualizations (Solara, Matplotlib, etc.) +- Styled dynamically by replacing `currentColor` in the SVG string + +## Design Guidelines + +### File Naming +- **Lowercase with underscores**: `person.svg`, `happy_face.svg` +- **Icon name = filename without extension**: `person.svg` → `"person"` +- **Descriptive and concise**: Prefer `arrow_up` over `arr_u` + +### SVG Standards +- **ViewBox**: Use `0 0 32 32` for consistency +- **Dynamic coloring**: Use `fill="currentColor"` to enable programmatic color control +- **Minimal paths**: Keep SVG markup simple for performance +- **No embedded styles**: Avoid `