bsplot 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bsplot/__init__.py +23 -0
- bsplot/anat.py +55 -0
- bsplot/animate.py +194 -0
- bsplot/bioicons.py +777 -0
- bsplot/brain.py +188 -0
- bsplot/colors.py +1935 -0
- bsplot/data/HCP_avg-SC.txt +379 -0
- bsplot/data/MNI152.rh.pial +0 -0
- bsplot/data/STN_lh.nii.gz +0 -0
- bsplot/data/STN_rh.nii.gz +0 -0
- bsplot/data/__init__.py +22 -0
- bsplot/data/left_electrode.ply +257270 -0
- bsplot/data/parcellations/HCP-MMP1.L.label.gii +442 -0
- bsplot/data/parcellations/HCP-MMP1.R.label.gii +442 -0
- bsplot/data/parcellations/code/split_hcpmmp1.sh +4 -0
- bsplot/data/right_electrode.ply +257270 -0
- bsplot/data/surface.py +112 -0
- bsplot/data/tpl-MNI152NLin2009b_atlas-hcpmmp1_desc-ordered_dseg.nii.gz +0 -0
- bsplot/figure.py +177 -0
- bsplot/graph/__init__.py +31 -0
- bsplot/graph/edges.py +312 -0
- bsplot/graph/flowchart.py +762 -0
- bsplot/graph/layout.py +201 -0
- bsplot/graph/network.py +974 -0
- bsplot/graph/nodes.py +452 -0
- bsplot/panels.py +110 -0
- bsplot/scientific_color_maps/__init__.py +0 -0
- bsplot/scientific_color_maps/_sync_from_zenodo_download.sh +1 -0
- bsplot/scientific_color_maps/batlowK.txt +256 -0
- bsplot/scientific_color_maps/batlowW.txt +256 -0
- bsplot/scientific_color_maps/cyclic/bamO.txt +256 -0
- bsplot/scientific_color_maps/cyclic/brocO.txt +256 -0
- bsplot/scientific_color_maps/cyclic/corkO.txt +256 -0
- bsplot/scientific_color_maps/cyclic/romaO.txt +256 -0
- bsplot/scientific_color_maps/cyclic/vikO.txt +256 -0
- bsplot/scientific_color_maps/diverging/bam.txt +256 -0
- bsplot/scientific_color_maps/diverging/berlin.txt +256 -0
- bsplot/scientific_color_maps/diverging/broc.txt +256 -0
- bsplot/scientific_color_maps/diverging/cork.txt +256 -0
- bsplot/scientific_color_maps/diverging/lisbon.txt +256 -0
- bsplot/scientific_color_maps/diverging/managua.txt +256 -0
- bsplot/scientific_color_maps/diverging/roma.txt +256 -0
- bsplot/scientific_color_maps/diverging/tofino.txt +256 -0
- bsplot/scientific_color_maps/diverging/vanimo.txt +256 -0
- bsplot/scientific_color_maps/diverging/vik.txt +256 -0
- bsplot/scientific_color_maps/multisequential/bukavu.txt +256 -0
- bsplot/scientific_color_maps/multisequential/fes.txt +256 -0
- bsplot/scientific_color_maps/multisequential/oleron.txt +256 -0
- bsplot/scientific_color_maps/naviaW.txt +256 -0
- bsplot/scientific_color_maps/sequential/acton.txt +256 -0
- bsplot/scientific_color_maps/sequential/bamako.txt +256 -0
- bsplot/scientific_color_maps/sequential/batlow.txt +256 -0
- bsplot/scientific_color_maps/sequential/bilbao.txt +256 -0
- bsplot/scientific_color_maps/sequential/buda.txt +256 -0
- bsplot/scientific_color_maps/sequential/davos.txt +256 -0
- bsplot/scientific_color_maps/sequential/devon.txt +256 -0
- bsplot/scientific_color_maps/sequential/glasgow.txt +256 -0
- bsplot/scientific_color_maps/sequential/grayC.txt +256 -0
- bsplot/scientific_color_maps/sequential/hawaii.txt +256 -0
- bsplot/scientific_color_maps/sequential/imola.txt +256 -0
- bsplot/scientific_color_maps/sequential/lajolla.txt +256 -0
- bsplot/scientific_color_maps/sequential/lapaz.txt +256 -0
- bsplot/scientific_color_maps/sequential/lipari.txt +256 -0
- bsplot/scientific_color_maps/sequential/navia.txt +256 -0
- bsplot/scientific_color_maps/sequential/nuuk.txt +256 -0
- bsplot/scientific_color_maps/sequential/oslo.txt +256 -0
- bsplot/scientific_color_maps/sequential/tokyo.txt +256 -0
- bsplot/scientific_color_maps/sequential/turku.txt +256 -0
- bsplot/streamlines.py +314 -0
- bsplot/style.py +791 -0
- bsplot/styles/black.mplstyle +78 -0
- bsplot/styles/bss.mplstyle +50 -0
- bsplot/styles/bwcomp.mplstyle +30 -0
- bsplot/styles/nature.mplstyle +57 -0
- bsplot/styles/transparent.mplstyle +16 -0
- bsplot/styles/tvbo.mplstyle +57 -0
- bsplot/surface.py +2370 -0
- bsplot/templates.py +270 -0
- bsplot/text.py +199 -0
- bsplot/text2obj.py +59 -0
- bsplot/timeseries.py +10 -0
- bsplot/utils.py +13 -0
- bsplot/volume.py +958 -0
- bsplot-0.0.2.dist-info/METADATA +60 -0
- bsplot-0.0.2.dist-info/RECORD +89 -0
- bsplot-0.0.2.dist-info/WHEEL +5 -0
- bsplot-0.0.2.dist-info/entry_points.txt +2 -0
- bsplot-0.0.2.dist-info/licenses/LICENSE +193 -0
- bsplot-0.0.2.dist-info/top_level.txt +1 -0
bsplot/bioicons.py
ADDED
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bioicons Interface for matplotlib icon plotting.
|
|
3
|
+
|
|
4
|
+
This module provides an interface to fetch and plot scientific icons from
|
|
5
|
+
bioicons.com - a free, open-source library of scientific icons.
|
|
6
|
+
|
|
7
|
+
The module can be used to annotate any matplotlib axis with scientific icons,
|
|
8
|
+
making it ideal for creating publication-quality figures with consistent
|
|
9
|
+
scientific iconography.
|
|
10
|
+
|
|
11
|
+
Icons are organized by license/category/author on bioicons.com:
|
|
12
|
+
- Licenses: CC-0, CC-BY-3.0, CC-BY-4.0, CC-BY-SA, MIT, BSD
|
|
13
|
+
- Categories: Amino-Acids, Machine Learning, Animals, Blood Immunology,
|
|
14
|
+
Cell culture, Chemistry, Genetics, Lab apparatus, etc.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
------
|
|
18
|
+
from bsplot.bioicons import BioIcons, plot_icon
|
|
19
|
+
|
|
20
|
+
# Initialize the icons interface
|
|
21
|
+
icons = BioIcons()
|
|
22
|
+
|
|
23
|
+
# Plot a single icon on any matplotlib axis
|
|
24
|
+
fig, ax = plt.subplots()
|
|
25
|
+
ax.plot([0, 1, 2], [1, 2, 1]) # Your plot
|
|
26
|
+
plot_icon("neuron", ax=ax, x=1, y=2, size=0.15) # Add icon annotation
|
|
27
|
+
|
|
28
|
+
# Or use the icons object for multiple icons
|
|
29
|
+
icons.plot("brain", ax=ax, x=0.5, y=1.5, size=0.1)
|
|
30
|
+
|
|
31
|
+
# Search for icons
|
|
32
|
+
icons.search("cell") # Find all icons with "cell" in the name
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import io
|
|
38
|
+
import json
|
|
39
|
+
import hashlib
|
|
40
|
+
import warnings
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Optional, Union, Dict, Any, List
|
|
43
|
+
from urllib.request import urlopen, Request
|
|
44
|
+
from urllib.error import URLError, HTTPError
|
|
45
|
+
|
|
46
|
+
import matplotlib.pyplot as plt
|
|
47
|
+
import matplotlib.patches as mpatches
|
|
48
|
+
import matplotlib.image as mpimg
|
|
49
|
+
from matplotlib.offsetbox import OffsetImage, AnnotationBbox
|
|
50
|
+
from matplotlib.axes import Axes
|
|
51
|
+
from matplotlib.figure import Figure
|
|
52
|
+
import numpy as np
|
|
53
|
+
|
|
54
|
+
# Try to import optional dependencies
|
|
55
|
+
try:
|
|
56
|
+
from PIL import Image
|
|
57
|
+
HAS_PIL = True
|
|
58
|
+
except ImportError:
|
|
59
|
+
HAS_PIL = False
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
import cairosvg
|
|
63
|
+
HAS_CAIROSVG = True
|
|
64
|
+
except ImportError:
|
|
65
|
+
HAS_CAIROSVG = False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Default cache directory for downloaded icons
|
|
69
|
+
DEFAULT_CACHE_DIR = Path.home() / ".cache" / "bsplot" / "bioicons"
|
|
70
|
+
|
|
71
|
+
# Bioicons.com base URL for icons
|
|
72
|
+
BIOICONS_BASE_URL = "https://bioicons.com/icons"
|
|
73
|
+
BIOICONS_INDEX_URL = "https://bioicons.com/icons/icons.json"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class BioIcons:
|
|
77
|
+
"""
|
|
78
|
+
Interface to fetch and manage bioicons for matplotlib plots.
|
|
79
|
+
|
|
80
|
+
This class provides methods to load, cache, and plot scientific icons
|
|
81
|
+
from bioicons.com on any matplotlib axis.
|
|
82
|
+
|
|
83
|
+
Parameters
|
|
84
|
+
----------
|
|
85
|
+
cache_dir : str or Path, optional
|
|
86
|
+
Directory to cache downloaded icons. Defaults to ~/.cache/bsplot/bioicons
|
|
87
|
+
local_icons_dir : str or Path, optional
|
|
88
|
+
Directory containing local icon files (SVG or PNG) to use instead of fetching.
|
|
89
|
+
|
|
90
|
+
Examples
|
|
91
|
+
--------
|
|
92
|
+
>>> import matplotlib.pyplot as plt
|
|
93
|
+
>>> from bsplot.bioicons import BioIcons
|
|
94
|
+
>>>
|
|
95
|
+
>>> icons = BioIcons()
|
|
96
|
+
>>> fig, ax = plt.subplots()
|
|
97
|
+
>>> ax.plot([0, 1, 2], [1, 2, 1])
|
|
98
|
+
>>> icons.plot("neuron", ax=ax, x=1, y=2, size=0.15)
|
|
99
|
+
>>> plt.show()
|
|
100
|
+
|
|
101
|
+
>>> # Search for icons
|
|
102
|
+
>>> icons.search("cell")
|
|
103
|
+
>>>
|
|
104
|
+
>>> # Get icon by full path
|
|
105
|
+
>>> icons.plot("cc-0/Genetics/Simon Dürr/neuron", ax=ax, x=0, y=0, size=0.1)
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
cache_dir: Optional[Union[str, Path]] = None,
|
|
111
|
+
local_icons_dir: Optional[Union[str, Path]] = None,
|
|
112
|
+
):
|
|
113
|
+
self.cache_dir = Path(cache_dir) if cache_dir else DEFAULT_CACHE_DIR
|
|
114
|
+
self.local_icons_dir = Path(local_icons_dir) if local_icons_dir else None
|
|
115
|
+
self._icon_cache: Dict[str, np.ndarray] = {}
|
|
116
|
+
self._index: Optional[List[Dict[str, str]]] = None
|
|
117
|
+
|
|
118
|
+
# Create cache directory if it doesn't exist
|
|
119
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
|
|
121
|
+
def _get_cache_path(self, icon_id: str, ext: str = "svg") -> Path:
|
|
122
|
+
"""Get the cache file path for an icon."""
|
|
123
|
+
# Use hash to handle special characters in icon IDs
|
|
124
|
+
hash_id = hashlib.md5(icon_id.encode()).hexdigest()[:12]
|
|
125
|
+
safe_name = icon_id.replace("/", "_").replace(" ", "_")
|
|
126
|
+
return self.cache_dir / f"{hash_id}_{safe_name}.{ext}"
|
|
127
|
+
|
|
128
|
+
def _fetch_index(self) -> List[Dict[str, str]]:
|
|
129
|
+
"""Fetch the icon index from bioicons.com."""
|
|
130
|
+
if self._index is not None:
|
|
131
|
+
return self._index
|
|
132
|
+
|
|
133
|
+
# Check for cached index
|
|
134
|
+
index_cache = self.cache_dir / "icons.json"
|
|
135
|
+
if index_cache.exists():
|
|
136
|
+
try:
|
|
137
|
+
with open(index_cache, "r") as f:
|
|
138
|
+
self._index = json.load(f)
|
|
139
|
+
return self._index
|
|
140
|
+
except (json.JSONDecodeError, IOError):
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
# Fetch from remote
|
|
144
|
+
try:
|
|
145
|
+
req = Request(
|
|
146
|
+
BIOICONS_INDEX_URL,
|
|
147
|
+
headers={"User-Agent": "bsplot/1.0"}
|
|
148
|
+
)
|
|
149
|
+
with urlopen(req, timeout=10) as response:
|
|
150
|
+
data = response.read().decode("utf-8")
|
|
151
|
+
self._index = json.loads(data)
|
|
152
|
+
|
|
153
|
+
# Cache the index
|
|
154
|
+
with open(index_cache, "w") as f:
|
|
155
|
+
f.write(data)
|
|
156
|
+
|
|
157
|
+
return self._index
|
|
158
|
+
except (URLError, HTTPError) as e:
|
|
159
|
+
warnings.warn(f"Failed to fetch icon index: {e}")
|
|
160
|
+
return []
|
|
161
|
+
except Exception as e:
|
|
162
|
+
warnings.warn(f"Error processing icon index: {e}")
|
|
163
|
+
return []
|
|
164
|
+
|
|
165
|
+
def search(self, query: str, category: Optional[str] = None,
|
|
166
|
+
license: Optional[str] = None) -> List[Dict[str, str]]:
|
|
167
|
+
"""
|
|
168
|
+
Search for icons matching the query.
|
|
169
|
+
|
|
170
|
+
Parameters
|
|
171
|
+
----------
|
|
172
|
+
query : str
|
|
173
|
+
Search query (matches icon name, category, or author)
|
|
174
|
+
category : str, optional
|
|
175
|
+
Filter by category
|
|
176
|
+
license : str, optional
|
|
177
|
+
Filter by license type (e.g., "cc-0", "cc-by")
|
|
178
|
+
|
|
179
|
+
Returns
|
|
180
|
+
-------
|
|
181
|
+
list of dict
|
|
182
|
+
List of matching icons with keys: name, category, license, author
|
|
183
|
+
"""
|
|
184
|
+
index = self._fetch_index()
|
|
185
|
+
query_lower = query.lower()
|
|
186
|
+
|
|
187
|
+
results = []
|
|
188
|
+
for icon in index:
|
|
189
|
+
# Match query against name, category, or author
|
|
190
|
+
if (query_lower in icon.get("name", "").lower() or
|
|
191
|
+
query_lower in icon.get("category", "").lower() or
|
|
192
|
+
query_lower in icon.get("author", "").lower()):
|
|
193
|
+
|
|
194
|
+
# Apply filters
|
|
195
|
+
if category and icon.get("category", "").lower() != category.lower():
|
|
196
|
+
continue
|
|
197
|
+
if license and license.lower() not in icon.get("license", "").lower():
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
results.append(icon)
|
|
201
|
+
|
|
202
|
+
return results
|
|
203
|
+
|
|
204
|
+
def list_categories(self) -> List[str]:
|
|
205
|
+
"""Get list of all available icon categories."""
|
|
206
|
+
index = self._fetch_index()
|
|
207
|
+
categories = set(icon.get("category", "") for icon in index)
|
|
208
|
+
return sorted(cat for cat in categories if cat)
|
|
209
|
+
|
|
210
|
+
def list_licenses(self) -> List[str]:
|
|
211
|
+
"""Get list of all available licenses."""
|
|
212
|
+
index = self._fetch_index()
|
|
213
|
+
licenses = set(icon.get("license", "") for icon in index)
|
|
214
|
+
return sorted(lic for lic in licenses if lic)
|
|
215
|
+
|
|
216
|
+
def _build_icon_url(self, icon_info: Dict[str, str]) -> str:
|
|
217
|
+
"""Build the URL for an icon from its info dict."""
|
|
218
|
+
license_dir = icon_info.get("license", "").replace(" ", "%20")
|
|
219
|
+
category = icon_info.get("category", "").replace(" ", "%20")
|
|
220
|
+
author = icon_info.get("author", "").replace(" ", "%20")
|
|
221
|
+
name = icon_info.get("name", "").replace(" ", "%20")
|
|
222
|
+
|
|
223
|
+
return f"{BIOICONS_BASE_URL}/{license_dir}/{category}/{author}/{name}.svg"
|
|
224
|
+
|
|
225
|
+
def _find_icon(self, icon_name: str) -> Optional[Dict[str, str]]:
|
|
226
|
+
"""
|
|
227
|
+
Find an icon by name in the index.
|
|
228
|
+
|
|
229
|
+
If icon_name contains '/', treat it as a full path.
|
|
230
|
+
Otherwise, search by name and return the first match.
|
|
231
|
+
"""
|
|
232
|
+
# Check if it's a full path
|
|
233
|
+
if "/" in icon_name:
|
|
234
|
+
parts = icon_name.split("/")
|
|
235
|
+
if len(parts) == 4:
|
|
236
|
+
return {
|
|
237
|
+
"license": parts[0],
|
|
238
|
+
"category": parts[1],
|
|
239
|
+
"author": parts[2],
|
|
240
|
+
"name": parts[3].replace(".svg", ""),
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
# Search by name
|
|
244
|
+
index = self._fetch_index()
|
|
245
|
+
for icon in index:
|
|
246
|
+
if icon.get("name", "").lower() == icon_name.lower():
|
|
247
|
+
return icon
|
|
248
|
+
# Also match without extension
|
|
249
|
+
if icon.get("name", "").lower().replace(".svg", "") == icon_name.lower():
|
|
250
|
+
return icon
|
|
251
|
+
|
|
252
|
+
# Partial match
|
|
253
|
+
for icon in index:
|
|
254
|
+
if icon_name.lower() in icon.get("name", "").lower():
|
|
255
|
+
return icon
|
|
256
|
+
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
def _load_from_local(self, icon_name: str) -> Optional[np.ndarray]:
|
|
260
|
+
"""Try to load icon from local icons directory."""
|
|
261
|
+
if self.local_icons_dir is None:
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
# Try different file extensions
|
|
265
|
+
for ext in ["svg", "png", "jpg", "jpeg"]:
|
|
266
|
+
icon_path = self.local_icons_dir / f"{icon_name}.{ext}"
|
|
267
|
+
if icon_path.exists():
|
|
268
|
+
return self._load_image_file(icon_path)
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
def _load_from_cache(self, icon_id: str) -> Optional[np.ndarray]:
|
|
272
|
+
"""Try to load icon from cache."""
|
|
273
|
+
for ext in ["png", "svg"]:
|
|
274
|
+
cache_path = self._get_cache_path(icon_id, ext)
|
|
275
|
+
if cache_path.exists():
|
|
276
|
+
return self._load_image_file(cache_path)
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
def _load_image_file(self, path: Path) -> np.ndarray:
|
|
280
|
+
"""Load an image file and return as numpy array."""
|
|
281
|
+
if path.suffix.lower() == ".svg":
|
|
282
|
+
return self._load_svg(path)
|
|
283
|
+
else:
|
|
284
|
+
if HAS_PIL:
|
|
285
|
+
img = Image.open(path).convert("RGBA")
|
|
286
|
+
return np.array(img)
|
|
287
|
+
else:
|
|
288
|
+
return mpimg.imread(str(path))
|
|
289
|
+
|
|
290
|
+
def _load_svg(self, path: Path) -> np.ndarray:
|
|
291
|
+
"""Load SVG file and convert to numpy array."""
|
|
292
|
+
if not HAS_CAIROSVG:
|
|
293
|
+
raise ImportError(
|
|
294
|
+
"cairosvg is required to load SVG files. "
|
|
295
|
+
"Install with: pip install cairosvg"
|
|
296
|
+
)
|
|
297
|
+
if not HAS_PIL:
|
|
298
|
+
raise ImportError(
|
|
299
|
+
"PIL is required to process images. "
|
|
300
|
+
"Install with: pip install pillow"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# Convert SVG to PNG in memory
|
|
304
|
+
# unsafe=True allows SVGs with external entities (some bioicons use these)
|
|
305
|
+
png_data = cairosvg.svg2png(
|
|
306
|
+
url=str(path), output_width=256, output_height=256, unsafe=True
|
|
307
|
+
)
|
|
308
|
+
img = Image.open(io.BytesIO(png_data)).convert("RGBA")
|
|
309
|
+
return np.array(img)
|
|
310
|
+
|
|
311
|
+
def _load_svg_from_bytes(self, svg_data: bytes) -> np.ndarray:
|
|
312
|
+
"""Load SVG from bytes and convert to numpy array."""
|
|
313
|
+
if not HAS_CAIROSVG:
|
|
314
|
+
raise ImportError(
|
|
315
|
+
"cairosvg is required to load SVG files. "
|
|
316
|
+
"Install with: pip install cairosvg"
|
|
317
|
+
)
|
|
318
|
+
if not HAS_PIL:
|
|
319
|
+
raise ImportError(
|
|
320
|
+
"PIL is required to process images. "
|
|
321
|
+
"Install with: pip install pillow"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# Convert SVG to PNG in memory
|
|
325
|
+
# unsafe=True allows SVGs with external entities (some bioicons use these)
|
|
326
|
+
png_data = cairosvg.svg2png(
|
|
327
|
+
bytestring=svg_data, output_width=256, output_height=256, unsafe=True
|
|
328
|
+
)
|
|
329
|
+
img = Image.open(io.BytesIO(png_data)).convert("RGBA")
|
|
330
|
+
return np.array(img)
|
|
331
|
+
|
|
332
|
+
def _fetch_icon(self, icon_name: str) -> Optional[np.ndarray]:
|
|
333
|
+
"""
|
|
334
|
+
Fetch icon from bioicons.com.
|
|
335
|
+
"""
|
|
336
|
+
# Check in-memory cache first
|
|
337
|
+
if icon_name in self._icon_cache:
|
|
338
|
+
return self._icon_cache[icon_name]
|
|
339
|
+
|
|
340
|
+
# Check local directory
|
|
341
|
+
img = self._load_from_local(icon_name)
|
|
342
|
+
if img is not None:
|
|
343
|
+
self._icon_cache[icon_name] = img
|
|
344
|
+
return img
|
|
345
|
+
|
|
346
|
+
# Check file cache
|
|
347
|
+
img = self._load_from_cache(icon_name)
|
|
348
|
+
if img is not None:
|
|
349
|
+
self._icon_cache[icon_name] = img
|
|
350
|
+
return img
|
|
351
|
+
|
|
352
|
+
# Find icon in index
|
|
353
|
+
icon_info = self._find_icon(icon_name)
|
|
354
|
+
if icon_info is None:
|
|
355
|
+
warnings.warn(
|
|
356
|
+
f"Icon '{icon_name}' not found. Use search() to find available icons."
|
|
357
|
+
)
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
# Build URL and fetch
|
|
361
|
+
url = self._build_icon_url(icon_info)
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
req = Request(url, headers={"User-Agent": "bsplot/1.0"})
|
|
365
|
+
with urlopen(req, timeout=10) as response:
|
|
366
|
+
if response.status == 200:
|
|
367
|
+
svg_data = response.read()
|
|
368
|
+
|
|
369
|
+
# Cache the SVG
|
|
370
|
+
cache_path = self._get_cache_path(icon_name, "svg")
|
|
371
|
+
with open(cache_path, "wb") as f:
|
|
372
|
+
f.write(svg_data)
|
|
373
|
+
|
|
374
|
+
# Convert to numpy array
|
|
375
|
+
img = self._load_svg_from_bytes(svg_data)
|
|
376
|
+
self._icon_cache[icon_name] = img
|
|
377
|
+
return img
|
|
378
|
+
|
|
379
|
+
except (URLError, HTTPError) as e:
|
|
380
|
+
warnings.warn(f"Failed to fetch icon '{icon_name}': {e}")
|
|
381
|
+
except ImportError as e:
|
|
382
|
+
warnings.warn(str(e))
|
|
383
|
+
except Exception as e:
|
|
384
|
+
warnings.warn(f"Error processing icon '{icon_name}': {e}")
|
|
385
|
+
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
def get_icon(self, icon_name: str) -> Optional[np.ndarray]:
|
|
389
|
+
"""
|
|
390
|
+
Get an icon as a numpy array.
|
|
391
|
+
|
|
392
|
+
Parameters
|
|
393
|
+
----------
|
|
394
|
+
icon_name : str
|
|
395
|
+
Name of the icon to fetch. Can be:
|
|
396
|
+
- A simple name like "neuron" (will search and use first match)
|
|
397
|
+
- A full path like "cc-0/Genetics/Simon Dürr/neuron"
|
|
398
|
+
|
|
399
|
+
Returns
|
|
400
|
+
-------
|
|
401
|
+
np.ndarray or None
|
|
402
|
+
Icon as RGBA numpy array, or None if not found
|
|
403
|
+
"""
|
|
404
|
+
return self._fetch_icon(icon_name)
|
|
405
|
+
|
|
406
|
+
def _create_placeholder(
|
|
407
|
+
self,
|
|
408
|
+
icon_name: str,
|
|
409
|
+
size: int = 64,
|
|
410
|
+
color: str = "#4a90e2",
|
|
411
|
+
) -> np.ndarray:
|
|
412
|
+
"""Create a placeholder icon when the actual icon is not available."""
|
|
413
|
+
if not HAS_PIL:
|
|
414
|
+
# Return a simple colored square
|
|
415
|
+
arr = np.ones((size, size, 4), dtype=np.float32)
|
|
416
|
+
if color.startswith("#"):
|
|
417
|
+
r = int(color[1:3], 16) / 255
|
|
418
|
+
g = int(color[3:5], 16) / 255
|
|
419
|
+
b = int(color[5:7], 16) / 255
|
|
420
|
+
else:
|
|
421
|
+
r, g, b = 0.3, 0.6, 0.9
|
|
422
|
+
arr[:, :, 0] = r
|
|
423
|
+
arr[:, :, 1] = g
|
|
424
|
+
arr[:, :, 2] = b
|
|
425
|
+
arr[:, :, 3] = 0.8
|
|
426
|
+
return arr
|
|
427
|
+
|
|
428
|
+
from PIL import Image, ImageDraw
|
|
429
|
+
|
|
430
|
+
img = Image.new("RGBA", (size, size), (255, 255, 255, 0))
|
|
431
|
+
draw = ImageDraw.Draw(img)
|
|
432
|
+
|
|
433
|
+
# Parse color
|
|
434
|
+
if color.startswith("#"):
|
|
435
|
+
r = int(color[1:3], 16)
|
|
436
|
+
g = int(color[3:5], 16)
|
|
437
|
+
b = int(color[5:7], 16)
|
|
438
|
+
fill_color = (r, g, b, 200)
|
|
439
|
+
else:
|
|
440
|
+
fill_color = (74, 144, 226, 200)
|
|
441
|
+
|
|
442
|
+
# Draw a rounded rectangle
|
|
443
|
+
margin = size // 8
|
|
444
|
+
draw.rounded_rectangle(
|
|
445
|
+
[margin, margin, size - margin, size - margin],
|
|
446
|
+
radius=size // 6,
|
|
447
|
+
fill=fill_color,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# Add first letter of icon name
|
|
451
|
+
letter = icon_name[0].upper() if icon_name else "?"
|
|
452
|
+
bbox = draw.textbbox((0, 0), letter)
|
|
453
|
+
text_w = bbox[2] - bbox[0]
|
|
454
|
+
text_h = bbox[3] - bbox[1]
|
|
455
|
+
text_x = (size - text_w) // 2
|
|
456
|
+
text_y = (size - text_h) // 2 - 2
|
|
457
|
+
draw.text((text_x, text_y), letter, fill=(255, 255, 255, 255))
|
|
458
|
+
|
|
459
|
+
return np.array(img)
|
|
460
|
+
|
|
461
|
+
def plot(
|
|
462
|
+
self,
|
|
463
|
+
icon_name: str,
|
|
464
|
+
ax: Optional[Axes] = None,
|
|
465
|
+
x: float = 0.5,
|
|
466
|
+
y: float = 0.5,
|
|
467
|
+
size: float = 0.1,
|
|
468
|
+
zoom: float = 1.0,
|
|
469
|
+
alpha: float = 1.0,
|
|
470
|
+
zorder: int = 10,
|
|
471
|
+
frameon: bool = False,
|
|
472
|
+
box_alignment: tuple = (0.5, 0.5),
|
|
473
|
+
xycoords: str = "data",
|
|
474
|
+
**kwargs,
|
|
475
|
+
) -> Optional[AnnotationBbox]:
|
|
476
|
+
"""
|
|
477
|
+
Plot an icon on a matplotlib axis.
|
|
478
|
+
|
|
479
|
+
Parameters
|
|
480
|
+
----------
|
|
481
|
+
icon_name : str
|
|
482
|
+
Name or path of the icon to plot
|
|
483
|
+
ax : matplotlib.axes.Axes, optional
|
|
484
|
+
Axis to plot on. If None, uses current axis.
|
|
485
|
+
x : float
|
|
486
|
+
X coordinate for icon placement
|
|
487
|
+
y : float
|
|
488
|
+
Y coordinate for icon placement
|
|
489
|
+
size : float
|
|
490
|
+
Size of the icon relative to the figure (default 0.1)
|
|
491
|
+
zoom : float
|
|
492
|
+
Additional zoom factor (default 1.0)
|
|
493
|
+
alpha : float
|
|
494
|
+
Transparency (0-1, default 1.0)
|
|
495
|
+
zorder : int
|
|
496
|
+
Z-order for layering (default 10)
|
|
497
|
+
frameon : bool
|
|
498
|
+
Whether to show a frame around the icon (default False)
|
|
499
|
+
box_alignment : tuple
|
|
500
|
+
Alignment of the icon box (default (0.5, 0.5) for center)
|
|
501
|
+
xycoords : str
|
|
502
|
+
Coordinate system for x, y (default "data")
|
|
503
|
+
**kwargs
|
|
504
|
+
Additional arguments passed to AnnotationBbox
|
|
505
|
+
|
|
506
|
+
Returns
|
|
507
|
+
-------
|
|
508
|
+
AnnotationBbox or None
|
|
509
|
+
The annotation box artist, or None if icon not found
|
|
510
|
+
"""
|
|
511
|
+
if ax is None:
|
|
512
|
+
ax = plt.gca()
|
|
513
|
+
|
|
514
|
+
# Get icon image
|
|
515
|
+
img = self._fetch_icon(icon_name)
|
|
516
|
+
if img is None:
|
|
517
|
+
# Use placeholder
|
|
518
|
+
warnings.warn(f"Using placeholder for '{icon_name}'")
|
|
519
|
+
img = self._create_placeholder(icon_name)
|
|
520
|
+
|
|
521
|
+
# Calculate zoom based on desired size
|
|
522
|
+
fig = ax.get_figure()
|
|
523
|
+
fig_width, fig_height = fig.get_size_inches()
|
|
524
|
+
dpi = fig.dpi
|
|
525
|
+
|
|
526
|
+
# Target size in pixels
|
|
527
|
+
target_size = size * min(fig_width, fig_height) * dpi
|
|
528
|
+
current_size = max(img.shape[:2])
|
|
529
|
+
calculated_zoom = (target_size / current_size) * zoom
|
|
530
|
+
|
|
531
|
+
# Create offset image
|
|
532
|
+
imagebox = OffsetImage(img, zoom=calculated_zoom, alpha=alpha)
|
|
533
|
+
|
|
534
|
+
# Create annotation box
|
|
535
|
+
ab = AnnotationBbox(
|
|
536
|
+
imagebox,
|
|
537
|
+
(x, y),
|
|
538
|
+
xycoords=xycoords,
|
|
539
|
+
frameon=frameon,
|
|
540
|
+
box_alignment=box_alignment,
|
|
541
|
+
zorder=zorder,
|
|
542
|
+
**kwargs,
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
ax.add_artist(ab)
|
|
546
|
+
return ab
|
|
547
|
+
|
|
548
|
+
def plot_grid(
|
|
549
|
+
self,
|
|
550
|
+
icon_names: List[str],
|
|
551
|
+
ncols: int = 4,
|
|
552
|
+
figsize: Optional[tuple] = None,
|
|
553
|
+
show_labels: bool = True,
|
|
554
|
+
title: Optional[str] = None,
|
|
555
|
+
) -> Figure:
|
|
556
|
+
"""
|
|
557
|
+
Plot a grid of icons.
|
|
558
|
+
|
|
559
|
+
Parameters
|
|
560
|
+
----------
|
|
561
|
+
icon_names : list of str
|
|
562
|
+
Names of icons to plot
|
|
563
|
+
ncols : int
|
|
564
|
+
Number of columns in the grid
|
|
565
|
+
figsize : tuple, optional
|
|
566
|
+
Figure size (width, height) in inches
|
|
567
|
+
show_labels : bool
|
|
568
|
+
Whether to show icon names below each icon
|
|
569
|
+
title : str, optional
|
|
570
|
+
Title for the figure
|
|
571
|
+
|
|
572
|
+
Returns
|
|
573
|
+
-------
|
|
574
|
+
matplotlib.figure.Figure
|
|
575
|
+
The figure containing the icon grid
|
|
576
|
+
"""
|
|
577
|
+
n_icons = len(icon_names)
|
|
578
|
+
nrows = (n_icons + ncols - 1) // ncols
|
|
579
|
+
|
|
580
|
+
if figsize is None:
|
|
581
|
+
figsize = (ncols * 2, nrows * (2.5 if show_labels else 2))
|
|
582
|
+
|
|
583
|
+
fig, axes = plt.subplots(nrows, ncols, figsize=figsize)
|
|
584
|
+
if nrows == 1 and ncols == 1:
|
|
585
|
+
axes = np.array([[axes]])
|
|
586
|
+
elif nrows == 1:
|
|
587
|
+
axes = axes.reshape(1, -1)
|
|
588
|
+
elif ncols == 1:
|
|
589
|
+
axes = axes.reshape(-1, 1)
|
|
590
|
+
|
|
591
|
+
for idx, name in enumerate(icon_names):
|
|
592
|
+
row = idx // ncols
|
|
593
|
+
col = idx % ncols
|
|
594
|
+
ax = axes[row, col]
|
|
595
|
+
|
|
596
|
+
img = self._fetch_icon(name)
|
|
597
|
+
if img is None:
|
|
598
|
+
img = self._create_placeholder(name)
|
|
599
|
+
|
|
600
|
+
ax.imshow(img)
|
|
601
|
+
ax.axis("off")
|
|
602
|
+
|
|
603
|
+
if show_labels:
|
|
604
|
+
ax.set_title(name, fontsize=9, wrap=True)
|
|
605
|
+
|
|
606
|
+
# Hide empty subplots
|
|
607
|
+
for idx in range(n_icons, nrows * ncols):
|
|
608
|
+
row = idx // ncols
|
|
609
|
+
col = idx % ncols
|
|
610
|
+
axes[row, col].axis("off")
|
|
611
|
+
|
|
612
|
+
if title:
|
|
613
|
+
fig.suptitle(title, fontsize=14)
|
|
614
|
+
|
|
615
|
+
plt.tight_layout()
|
|
616
|
+
return fig
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
class IconAnnotation:
|
|
620
|
+
"""
|
|
621
|
+
Helper class for annotating matplotlib plots with icons.
|
|
622
|
+
|
|
623
|
+
This class provides a simpler interface for adding icons as annotations
|
|
624
|
+
to existing matplotlib figures.
|
|
625
|
+
|
|
626
|
+
Parameters
|
|
627
|
+
----------
|
|
628
|
+
icons : BioIcons, optional
|
|
629
|
+
BioIcons instance to use. If None, creates a new one.
|
|
630
|
+
"""
|
|
631
|
+
|
|
632
|
+
def __init__(self, icons: Optional[BioIcons] = None):
|
|
633
|
+
self.icons = icons or BioIcons()
|
|
634
|
+
|
|
635
|
+
def annotate(
|
|
636
|
+
self,
|
|
637
|
+
ax: Axes,
|
|
638
|
+
icon_name: str,
|
|
639
|
+
xy: tuple,
|
|
640
|
+
size: float = 0.1,
|
|
641
|
+
**kwargs,
|
|
642
|
+
) -> Optional[AnnotationBbox]:
|
|
643
|
+
"""
|
|
644
|
+
Add an icon annotation to an axis.
|
|
645
|
+
|
|
646
|
+
Parameters
|
|
647
|
+
----------
|
|
648
|
+
ax : matplotlib.axes.Axes
|
|
649
|
+
Axis to annotate
|
|
650
|
+
icon_name : str
|
|
651
|
+
Name of the icon
|
|
652
|
+
xy : tuple
|
|
653
|
+
(x, y) position for the icon
|
|
654
|
+
size : float
|
|
655
|
+
Size of the icon
|
|
656
|
+
**kwargs
|
|
657
|
+
Additional arguments passed to BioIcons.plot()
|
|
658
|
+
"""
|
|
659
|
+
return self.icons.plot(icon_name, ax=ax, x=xy[0], y=xy[1], size=size, **kwargs)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
# Module-level convenience functions
|
|
663
|
+
|
|
664
|
+
_default_icons: Optional[BioIcons] = None
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _get_default_icons() -> BioIcons:
|
|
668
|
+
"""Get or create the default BioIcons instance."""
|
|
669
|
+
global _default_icons
|
|
670
|
+
if _default_icons is None:
|
|
671
|
+
_default_icons = BioIcons()
|
|
672
|
+
return _default_icons
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def plot_icon(
|
|
676
|
+
icon_name: str,
|
|
677
|
+
ax: Optional[Axes] = None,
|
|
678
|
+
x: float = 0.5,
|
|
679
|
+
y: float = 0.5,
|
|
680
|
+
size: float = 0.1,
|
|
681
|
+
**kwargs,
|
|
682
|
+
) -> Optional[AnnotationBbox]:
|
|
683
|
+
"""
|
|
684
|
+
Plot an icon on a matplotlib axis.
|
|
685
|
+
|
|
686
|
+
This is a convenience function that uses the default BioIcons instance.
|
|
687
|
+
|
|
688
|
+
Parameters
|
|
689
|
+
----------
|
|
690
|
+
icon_name : str
|
|
691
|
+
Name or path of the icon to plot
|
|
692
|
+
ax : matplotlib.axes.Axes, optional
|
|
693
|
+
Axis to plot on. If None, uses current axis.
|
|
694
|
+
x : float
|
|
695
|
+
X coordinate for icon placement
|
|
696
|
+
y : float
|
|
697
|
+
Y coordinate for icon placement
|
|
698
|
+
size : float
|
|
699
|
+
Size of the icon relative to the figure (default 0.1)
|
|
700
|
+
**kwargs
|
|
701
|
+
Additional arguments passed to BioIcons.plot()
|
|
702
|
+
|
|
703
|
+
Returns
|
|
704
|
+
-------
|
|
705
|
+
AnnotationBbox or None
|
|
706
|
+
The annotation box artist, or None if icon not found
|
|
707
|
+
|
|
708
|
+
Examples
|
|
709
|
+
--------
|
|
710
|
+
>>> import matplotlib.pyplot as plt
|
|
711
|
+
>>> from bsplot.bioicons import plot_icon
|
|
712
|
+
>>>
|
|
713
|
+
>>> fig, ax = plt.subplots()
|
|
714
|
+
>>> ax.plot([0, 1, 2], [0, 1, 0])
|
|
715
|
+
>>> plot_icon("neuron", ax=ax, x=1, y=1, size=0.15)
|
|
716
|
+
>>> plt.show()
|
|
717
|
+
"""
|
|
718
|
+
icons = _get_default_icons()
|
|
719
|
+
return icons.plot(icon_name, ax=ax, x=x, y=y, size=size, **kwargs)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def plot_icons_grid(
|
|
723
|
+
icon_names: List[str],
|
|
724
|
+
ncols: int = 4,
|
|
725
|
+
**kwargs,
|
|
726
|
+
) -> Figure:
|
|
727
|
+
"""
|
|
728
|
+
Plot a grid of icons.
|
|
729
|
+
|
|
730
|
+
Parameters
|
|
731
|
+
----------
|
|
732
|
+
icon_names : list of str
|
|
733
|
+
Names of icons to plot
|
|
734
|
+
ncols : int
|
|
735
|
+
Number of columns in the grid
|
|
736
|
+
**kwargs
|
|
737
|
+
Additional arguments passed to BioIcons.plot_grid()
|
|
738
|
+
|
|
739
|
+
Returns
|
|
740
|
+
-------
|
|
741
|
+
matplotlib.figure.Figure
|
|
742
|
+
The figure containing the icon grid
|
|
743
|
+
"""
|
|
744
|
+
icons = _get_default_icons()
|
|
745
|
+
return icons.plot_grid(icon_names, ncols=ncols, **kwargs)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def search_icons(query: str, **kwargs) -> List[Dict[str, str]]:
|
|
749
|
+
"""
|
|
750
|
+
Search for icons matching the query.
|
|
751
|
+
|
|
752
|
+
Parameters
|
|
753
|
+
----------
|
|
754
|
+
query : str
|
|
755
|
+
Search query
|
|
756
|
+
**kwargs
|
|
757
|
+
Additional arguments passed to BioIcons.search()
|
|
758
|
+
|
|
759
|
+
Returns
|
|
760
|
+
-------
|
|
761
|
+
list of dict
|
|
762
|
+
Matching icons
|
|
763
|
+
"""
|
|
764
|
+
icons = _get_default_icons()
|
|
765
|
+
return icons.search(query, **kwargs)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def list_categories() -> List[str]:
|
|
769
|
+
"""Get list of all available icon categories."""
|
|
770
|
+
icons = _get_default_icons()
|
|
771
|
+
return icons.list_categories()
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def list_licenses() -> List[str]:
|
|
775
|
+
"""Get list of all available licenses."""
|
|
776
|
+
icons = _get_default_icons()
|
|
777
|
+
return icons.list_licenses()
|