Minimal Bundled SVG Agent Icons (refs #2857)
Minimal Bundled SVG Agent Icons
Summary
This PR introduces a minimal internal SVG agent icon library and helper module. It is intentionally small and focused on packaging + access + baseline performance, with no frontend integration yet. That will follow in a separate PR after reviewer feedback.
Changes
mesa/visualization/icons.py:list_icons()– returns available icon basenames.get_icon_svg(name)– returns raw SVG text (supportsmesa:<name>).
- 3 SVG icons under
mesa/visualization/icons/(usingfill="currentColor"for recoloring). Current icons:neutral_face,smiley,sad_update. mesa/visualization/icons/README.md– brief style/naming rules.tests/test_icons.py– listing and retrieval tests.pyproject.toml– added[tool.hatch.build]include patterns to package SVG assets (Hatch/hatchling is used; no MANIFEST.in).benchmarks/icons_benchmark.py– dev-only Python benchmark script (not included in package builds).
No existing visualization behavior is changed.
Rationale
Split the work:
- Asset + access layer (this PR) – low risk, easy to review and test.
- Frontend integration + performance comparison (next PR). This sequencing allows maintainers to validate packaging + API before adopting icon rendering.
Performance (Python benchmark)
Environment:
- Python: 3.13.x
- Packages: cairosvg (version), Pillow (version)
Method:
python benchmarks/icons_benchmark.py --icon neutral_face --n N --frames 120
Measures cold SVG read, SVG→raster conversion, and per-frame composition time (Pillow alpha compositing).
Results (neutral_face.svg):
| Agents (N) | svg_read_sec | svg_convert_sec | avg_frame_ms | p95_frame_ms |
|---|---|---|---|---|
| 100 | 0.000 | 0.013 | 0.367 | 0.393 |
| 500 | 0.000 | 0.014 | 1.956 | 2.001 |
| 1000 | 0.000 | 0.011 | 3.883 | 4.129 |
Interpretation:
- Cold read negligible.
- One-time SVG→raster conversion fast (~13 ms).
- Composition overhead for 100 agents is far below frame budget (<16 ms for 60 FPS).
- Larger-N measurements will be added, and browser DOM/SVG vs Canvas will be benchmarked in the follow-up after minimal integration.
Verification
pytest -qpasses locally.- Wheel build includes the SVG assets:
unzip -l dist/Mesa-*.whl | grep visualization/icons
- Runtime check:
python -c "from mesa.visualization import icons; print(icons.list_icons())"returns expected names. - Benchmark script runs successfully.
Follow-Up Plan (next PR)
- Add portrayal support:
{"icon": "<name>", "color": "#RRGGBB"}. - Implement Python-only Solara integration (inline SVG) and compare with cached raster composition.
- Benchmark browser frame times for N = 100 / 500 / 1000 agents (avg & p95).
- Consider metadata and/or sprite optimization if needed.
Notes
- Icons are simple originals under the repository’s Apache 2.0 license.
- Kept intentionally minimal: no metadata/gallery yet.
- Benchmark script is dev-only and excluded from builds.
Refs #2857
Performance benchmarks:
| Model | Size | Init time [95% CI] | Run time [95% CI] |
|---|---|---|---|
| BoltzmannWealth | small | 🔵 +0.2% [-0.7%, +1.1%] | 🔵 -0.4% [-0.5%, -0.2%] |
| BoltzmannWealth | large | 🔵 -1.0% [-2.3%, +0.2%] | 🔵 +1.0% [-1.8%, +3.4%] |
| Schelling | small | 🔵 +0.2% [-0.1%, +0.5%] | 🔵 +1.3% [+0.8%, +1.8%] |
| Schelling | large | 🔵 -0.0% [-0.5%, +0.4%] | 🔵 +1.4% [-0.1%, +2.7%] |
| WolfSheep | small | 🔵 +0.9% [+0.6%, +1.1%] | 🔵 +0.5% [+0.3%, +0.8%] |
| WolfSheep | large | 🔵 +0.1% [-1.1%, +1.3%] | 🔵 -1.0% [-1.9%, -0.2%] |
| BoidFlockers | small | 🔵 -1.6% [-2.4%, -0.8%] | 🔵 -0.1% [-0.3%, +0.1%] |
| BoidFlockers | large | 🔵 -1.6% [-2.3%, -0.9%] | 🔵 -0.9% [-1.3%, -0.6%] |
Thanks for the PR, especially with the different agent count benchmarks.
Could you compare the SVG drawing to the drawing of the default marker?
Sure, I would be happy to do it.
Comparison: Default Marker vs Rasterized SVG (Pillow composition)
Setup:
- Canvas: 800×600
- Per-agent size: 32×32
- Mode “marker”:
ImageDraw.ellipse(filled circle) - Mode “icon”: pre-rasterized SVG (
cairosvgonce), per-agentalpha_composite - Environment: Python 3.13 (macOS), local machine
Results:
| N agents | Mode | Avg frame time (ms) | P95 frame time (ms) | Notes |
|---|---|---|---|---|
| 100 | marker | 0.084 | 0.104 | |
| 100 | icon | 0.380 | 0.443 | SVG convert ~0.014 s |
| 500 | marker | 0.370 | 0.420 | |
| 500 | icon | 1.906 | 1.994 | SVG convert ~0.011 s |
| 1000 | marker | 0.731 | 0.769 | |
| 1000 | icon | 3.814 | 3.940 | SVG convert ~0.012 s |
Observations:
- The default marker is ~4–5× faster than rasterized SVG composition at N=100, and both scale roughly linearly with N.
- Rasterized SVG composition remains modest in absolute terms (~3.8 ms at N=1000 on this machine), but is consistently more expensive than drawing the primitive marker.
- The one-time SVG → PNG conversion cost is small (~11–14 ms) and amortized across frames.
Next steps:
- I’ll include a browser-side benchmark in the follow-up PR comparing: Should I do it ?
- Canvas primitive markers vs cached icon blits (ImageBitmap/drawImage)
- Inline SVG rendering
- That should provide a more direct comparison for the web-based visualization path.
@EwoutH let me know if you’d like additional sizes (e.g., N=2000) or alternative marker shapes/colors for completeness.
Thanks. Can you give a screenshot how the benchmark looks?
Yes, doing it
Sorry for the delay. Can I now build the Frontend for this ?
Thanks.
Can you explain how this integrates with our Solara visualisation and render backends? Or haven't you implemented that yet?
Thanks! I haven’t implemented the Solara integration yet in this PR. Here’s my plan for the follow-up :
Integration plan (Solara + render backends)
-
Solara (ComponentsView)
- Add a per-agent (or per-layer) render option to select:
default(ellipse marker)icon:<name>(bundled SVG)custom(user-supplied)
- Canvas backend: cache rasterized icons (
ImageBitmap) once, then draw per agent viadrawImage. Avoid any per-frame SVG parsing. - Inline-SVG backend: reference the icon as
<use>or<image>(data URL). Prefer Canvas for large N to reduce DOM overhead.
- Add a per-agent (or per-layer) render option to select:
-
Backend considerations
- Canvas 2D: icon caching + batched draws; scale via
drawImage. Color variants via pre-tinted bitmaps or simple composition (phase 2). - Inline SVG: fine for small agent counts; we’ll document the trade-off vs Canvas for larger N.
- Fallback: if an icon is missing/not loaded, fall back to the current default marker.
- Canvas 2D: icon caching + batched draws; scale via
-
API surface
- Introduce an optional rendering config read by ComponentsView, e.g.:
render_style = {"type": "icon", "name": "smiley", "size": 32} - Defaults unchanged so existing models render exactly as today unless users opt in.
- Introduce an optional rendering config read by ComponentsView, e.g.:
-
Performance
- Add a browser-side benchmark page comparing:
- Canvas primitive markers
- Canvas cached icon blits (
drawImage) - Inline SVG (
<use>)
- Complement the Python results in this PR; preload/cache icons to avoid runtime stalls.
- Add a browser-side benchmark page comparing:
What do you think about this ?
Observations:
- The default marker is ~4–5× faster than rasterized SVG composition at N=100, and both scale roughly linearly with N.
- Rasterized SVG composition remains modest in absolute terms (~3.8 ms at N=1000 on this machine), but is consistently more expensive than drawing the primitive marker.
- The one-time SVG → PNG conversion cost is small (~11–14 ms) and amortized across frames.
Assuming these numbers and observations transfer to our Solara visualization, can we do anything to optimize it? 4-5x overhead sounds like quite a lot.
Yes, for sure. I think we can definitely optimize to guarantee performance, immediate win is caching. We should ensure we only rasterize the SVG to a bitmap once , rather than per frame.
I have optimized it changes :
- altair_backend.py:
- Preserve original portrayal objects (“portrayals”) so SpaceRenderer can enrich with icon metadata.
- Suppress warnings for icon/icon_size keys.
- Render icons when arguments include icon_rasters (data URLs) by layering mark_image with mark_point.
- Benchmarks:
- backend_space_benchmark.py: single-run timing of per-frame render
- backend_space_benchmark_matrix.py: batch runs producing Markdown tables for multiple N and modes
Benchmark Results (120 frames)
| Backend | Mode | N | Avg ms | P95 ms | Icon Overhead |
|---|---|---|---|---|---|
| Altair | marker | 100 | 15.02 | 15.45 | baseline |
| Altair | icon | 100 | 15.10 | 15.56 | +0.5% |
| Altair | marker | 500 | 22.77 | 22.98 | baseline |
| Altair | icon | 500 | 22.72 | 23.32 | -0.2% |
| Altair | marker | 1000 | 31.49 | 32.08 | baseline |
| Altair | icon | 1000 | 31.97 | 32.66 | +1.5% |
| Altair | marker | 2000 | 50.96 | 51.47 | baseline |
| Altair | icon | 2000 | 51.57 | 52.17 | +1.2% |
Benchmark Results (60 frames)
| Backend | Mode | N | Avg ms | P95 ms | Icon Overhead |
|---|---|---|---|---|---|
| Altair | marker | 100 | 15.35 | 15.68 | baseline |
| Altair | icon | 100 | 15.35 | 15.79 | 0% |
| Altair | marker | 500 | 22.48 | 22.97 | baseline |
| Altair | icon | 500 | 23.16 | 23.34 | +3.0% |
| Altair | marker | 1000 | 31.72 | 32.24 | baseline |
| Altair | icon | 1000 | 32.07 | 32.71 | +1.1% |
Average icon overhead ~0.9–1.5% across test matrix (well below target of <50%).
That’s quite amazing! Thanks for the detailed benchmarks.
If you want you can move forward with Solara integration. Try to keep the implementation minimal / code complexity low. You can do that in a new PR if you want.
Thankyou, would be happy to do that