From 7b664e6cf2df106c3b8c096fc3a8d771467ecb4f Mon Sep 17 00:00:00 2001 From: Mani Joshi Date: Sat, 29 Nov 2025 19:31:57 +0530 Subject: [PATCH 1/7] add minimal bundled SVG agent icons --- benchmarks/icons_benchmark.py | 125 ++++++++++++++++++++++ mesa/visualization/icons.py | 44 ++++++++ mesa/visualization/icons/README.md | 71 ++++++++++++ mesa/visualization/icons/neutral_face.svg | 11 ++ mesa/visualization/icons/sad_face.svg | 11 ++ mesa/visualization/icons/smiley.svg | 11 ++ pyproject.toml | 5 + tests/test_icons.py | 25 +++++ 8 files changed, 303 insertions(+) create mode 100644 benchmarks/icons_benchmark.py create mode 100644 mesa/visualization/icons.py create mode 100644 mesa/visualization/icons/README.md create mode 100644 mesa/visualization/icons/neutral_face.svg create mode 100644 mesa/visualization/icons/sad_face.svg create mode 100644 mesa/visualization/icons/smiley.svg create mode 100644 tests/test_icons.py diff --git a/benchmarks/icons_benchmark.py b/benchmarks/icons_benchmark.py new file mode 100644 index 00000000000..e414c7d5ffa --- /dev/null +++ b/benchmarks/icons_benchmark.py @@ -0,0 +1,125 @@ +""" +benchmark for bundled SVG icons (Python-only, dev use). + +Measures: +- cold SVG read time +- SVG -> raster conversion time (via cairosvg) +- per-frame composition time for N icons drawn onto a Pillow canvas + +Usage: + pip install cairosvg pillow + python benchmarks/icon_benchmark.py --icon person --n 500 --frames 120 + +Environment info (record this in PR): + python -V + uname -a (Linux/macOS) or systeminfo (Windows) + CPU, RAM +""" + +import time +import argparse +import statistics +from io import BytesIO + +try: + import cairosvg + from PIL import Image +except Exception as e: + raise SystemExit("Requires 'cairosvg' and 'Pillow'. Install with: pip install cairosvg pillow") from e + +from mesa.visualization import icons as icons_module # your icons.py + + +def svg_to_pil_image(svg_text, scale=1.0): + 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="person", n=100, frames=60, canvas_size=(800, 600), icon_size=(32, 32), scale=1.0): + # load SVG + 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 + + # convert to raster + 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 + + # resize if needed + if pil_icon.size != icon_size: + pil_icon = pil_icon.resize(icon_size, resample=Image.LANCZOS) + + # grid positions + 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 = [] + 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)) + for (x, y) in positions: + canvas.alpha_composite(pil_icon, dest=(x, y)) + + # timed frames + frame_times_ms = [] + for _ in range(frames): + canvas = Image.new("RGBA", canvas_size, (255, 255, 255, 0)) + t0 = time.perf_counter() + for (x, y) in positions: + canvas.alpha_composite(pil_icon, dest=(x, y)) + 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 { + "icon": icon_name, + "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(): + ap = argparse.ArgumentParser() + 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( + 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() \ No newline at end of file diff --git a/mesa/visualization/icons.py b/mesa/visualization/icons.py new file mode 100644 index 00000000000..d7d17d5e266 --- /dev/null +++ b/mesa/visualization/icons.py @@ -0,0 +1,44 @@ +from __future__ import annotations +import importlib.resources +from typing import List + +ICONS_SUBDIR = "icons" +SVG_EXT = ".svg" + + +def _icons_package_root(): + """ + Returns a Traversable pointing at the icons subdirectory inside this package. + """ + # this module's __package__ is "mesa.visualization" + 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.sort() + return names + + +def get_icon_svg(name: str) -> str: + """ + 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") \ No newline at end of file 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 `