Skip to content

Commit b867d24

Browse files
authored
Fix color rendering when more than ~250 colors are present (#8)
* Wrap colors around, rather than repeating, when they exceed the maximum count * Add type annotations * Fix test deprecation warning
1 parent ea2ea2b commit b867d24

File tree

4 files changed

+131
-77
lines changed

4 files changed

+131
-77
lines changed

celerpy/visualize.py

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@
1212
from pathlib import Path
1313
from subprocess import TimeoutExpired
1414
from tempfile import NamedTemporaryFile
15-
from typing import Any, Optional, Union
15+
from typing import Any, NamedTuple, Optional, Union
1616

1717
import matplotlib.pyplot as plt
1818
import numpy as np
1919
from matplotlib import colormaps
20+
from matplotlib.axes import Axes as mpl_Axes
2021
from matplotlib.colors import BoundaryNorm, ListedColormap
2122

2223
from . import model, process
@@ -27,12 +28,39 @@
2728
_re_ptr = re.compile(r"0x[0-9a-f]+")
2829

2930

31+
class WrappingListedColormap(ListedColormap):
32+
"""A ListedColormap that wraps around when the number of colors is exceeded.
33+
34+
When more colors are requested than available, this colormap will cycle
35+
through the available colors and emit a warning.
36+
"""
37+
38+
def __init__(self, *args, **kwargs):
39+
super().__init__(*args, **kwargs)
40+
self._warned: bool = False
41+
42+
def __call__(self, X, *args, **kwargs):
43+
X = np.asarray(X)
44+
if not self._warned and (max_val := np.max(X)) >= self.N:
45+
warnings.warn(
46+
f"Color index {max_val} exceeds colormap size {self.N}. "
47+
"Colors will be reused cyclically.",
48+
stacklevel=1,
49+
)
50+
self._warned = True
51+
52+
# Wrap indices using modulo
53+
X_wrapped = np.mod(X, self.N)
54+
return super().__call__(X_wrapped, *args, **kwargs)
55+
56+
3057
def _register_cmaps():
3158
resources = files("celerpy._resources")
32-
cmap = ListedColormap(
33-
np.loadtxt(resources.joinpath("glasbey-light.txt")),
34-
name="glasbey_light",
35-
)
59+
with resources.joinpath("glasbey-light.txt").open("r") as f:
60+
cmap = WrappingListedColormap(
61+
np.loadtxt(f),
62+
name="glasbey_light",
63+
)
3664
try:
3765
colormaps.register(cmap)
3866
except ValueError as e:
@@ -97,10 +125,15 @@ class CelerGeo:
97125
image: Optional[model.ImageParams]
98126
volumes: dict[model.GeometryEngine, list[str]]
99127

128+
@classmethod
129+
def with_setup(cls, *args, **kwargs):
130+
"""Construct, forwarding args to ModelSetup."""
131+
return cls(setup=model.ModelSetup(*args, **kwargs))
132+
100133
@classmethod
101134
def from_filename(cls, path: Path):
102135
"""Construct from a geometry filename and default other setup."""
103-
return cls(model.ModelSetup(geometry_file=path))
136+
return cls.with_setup(geometry_file=path)
104137

105138
def __init__(self, setup: model.ModelSetup):
106139
# Create the process and attach stdin/stdout pipes
@@ -210,8 +243,15 @@ def __missing__(self, key: str):
210243
return result
211244

212245

213-
LabeledAxis = collections.namedtuple("LabeledAxis", ["label", "lo", "hi"])
214-
LabeledAxes = collections.namedtuple("LabeledAxes", ["x", "y"])
246+
class LabeledAxis(NamedTuple):
247+
label: str
248+
lo: float
249+
hi: float
250+
251+
252+
class LabeledAxes(NamedTuple):
253+
x: LabeledAxis
254+
y: LabeledAxis
215255

216256

217257
def calc_image_axes(image: model.ImageParams) -> LabeledAxes:
@@ -254,10 +294,10 @@ def __init__(self, celer_geo, image: model.ImageInput):
254294

255295
def __call__(
256296
self,
257-
ax,
297+
ax: mpl_Axes,
258298
geometry: Optional[model.GeometryEngine] = None,
259299
memspace: Optional[model.MemSpace] = None,
260-
colorbar=None,
300+
colorbar: Union[bool, None, mpl_Axes] = None,
261301
) -> dict[str, Any]:
262302
(trace_output, img) = self.celer_geo.trace(
263303
self.image, geometry=geometry, memspace=memspace
@@ -268,9 +308,9 @@ def __call__(
268308
(x, y) = self.axes
269309

270310
ax.set_xlabel(x.label)
271-
ax.set_xlim([x.lo, x.hi])
311+
ax.set_xlim((x.lo, x.hi))
272312
ax.set_ylabel(y.label)
273-
ax.set_ylim([y.lo, y.hi])
313+
ax.set_ylim((y.lo, y.hi))
274314
tr = trace_output.trace
275315
ax.set_title(f"{tr.geometry.name} ({tr.memspace.name})")
276316

@@ -279,7 +319,7 @@ def __call__(
279319
norm = BoundaryNorm(np.arange(len(volumes) + 1), len(volumes) + 1)
280320
im = ax.imshow(
281321
img,
282-
extent=[x.lo, x.hi, y.lo, y.hi],
322+
extent=(x.lo, x.hi, y.lo, y.hi),
283323
interpolation="none",
284324
norm=norm,
285325
cmap="glasbey_light",
@@ -292,11 +332,14 @@ def __call__(
292332
if colorbar:
293333
# Create colorbar
294334
bounds = norm.boundaries
295-
kwargs = {"ticks": bounds[:-1] + np.diff(bounds) / 2}
335+
kwargs: dict[str, Any] = {
336+
"ticks": bounds[:-1] + np.diff(bounds) / 2
337+
}
296338
if not isinstance(colorbar, bool):
297339
# User can specify a new axis to place the colorbar
298340
kwargs["cax"] = colorbar
299341
fig = ax.get_figure()
342+
assert fig is not None
300343
cbar = fig.colorbar(im, **kwargs)
301344
result["colorbar"] = cbar
302345

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ celerpy = "celerpy.cli:app"
3838

3939
[tool.mypy]
4040
plugins = [
41-
"numpy.typing.mypy_plugin",
4241
"pydantic.mypy"
4342
]
4443

requirements-dev.txt

Lines changed: 52 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -8,130 +8,140 @@ boltons==25.0.0
88
# via
99
# face
1010
# glom
11-
build==1.2.2.post1
11+
build==1.3.0
1212
# via celerpy (pyproject.toml)
1313
cfgv==3.4.0
1414
# via pre-commit
15-
click==8.1.8
15+
click==8.3.0
1616
# via typer
17-
contourpy==1.3.1
17+
contourpy==1.3.3
1818
# via matplotlib
19-
coverage==7.8.0
19+
coverage==7.10.7
2020
# via pytest-cov
2121
cycler==0.12.1
2222
# via matplotlib
2323
dapperdata==0.4.0
2424
# via celerpy (pyproject.toml)
25-
distlib==0.3.9
25+
distlib==0.4.0
2626
# via virtualenv
2727
face==24.0.0
2828
# via glom
29-
filelock==3.18.0
29+
filelock==3.19.1
3030
# via virtualenv
31-
fonttools==4.57.0
31+
fonttools==4.60.1
3232
# via matplotlib
3333
glom==24.11.0
3434
# via celerpy (pyproject.toml)
35-
identify==2.6.9
35+
identify==2.6.15
3636
# via pre-commit
3737
iniconfig==2.1.0
3838
# via pytest
39-
kiwisolver==1.4.8
39+
kiwisolver==1.4.9
4040
# via matplotlib
41-
markdown-it-py==3.0.0
41+
markdown-it-py==4.0.0
4242
# via rich
43-
matplotlib==3.10.1
43+
matplotlib==3.10.6
4444
# via celerpy (pyproject.toml)
4545
mdurl==0.1.2
4646
# via markdown-it-py
47-
mypy==1.15.0
47+
mypy==1.18.2
4848
# via celerpy (pyproject.toml)
49-
mypy-extensions==1.0.0
49+
mypy-extensions==1.1.0
5050
# via mypy
5151
nodeenv==1.9.1
5252
# via pre-commit
53-
numpy==2.2.4
53+
numpy==2.3.3
5454
# via
5555
# celerpy (pyproject.toml)
5656
# contourpy
5757
# matplotlib
58-
packaging==24.2
58+
packaging==25.0
5959
# via
6060
# build
6161
# matplotlib
6262
# pytest
63-
pillow==11.1.0
63+
pathspec==0.12.1
64+
# via mypy
65+
pillow==11.3.0
6466
# via matplotlib
65-
platformdirs==4.3.7
67+
platformdirs==4.4.0
6668
# via virtualenv
67-
pluggy==1.5.0
68-
# via pytest
69-
pre-commit==4.2.0
69+
pluggy==1.6.0
70+
# via
71+
# pytest
72+
# pytest-cov
73+
pre-commit==4.3.0
7074
# via celerpy (pyproject.toml)
71-
pydantic==2.11.2
75+
pydantic==2.11.10
7276
# via
7377
# celerpy (pyproject.toml)
7478
# dapperdata
7579
# pydantic-settings
76-
pydantic-core==2.33.1
80+
pydantic-core==2.33.2
7781
# via pydantic
78-
pydantic-settings==2.8.1
82+
pydantic-settings==2.11.0
7983
# via
8084
# celerpy (pyproject.toml)
8185
# dapperdata
82-
pygments==2.19.1
83-
# via rich
84-
pyparsing==3.2.3
86+
pygments==2.19.2
87+
# via
88+
# pytest
89+
# rich
90+
pyparsing==3.2.5
8591
# via matplotlib
8692
pyproject-hooks==1.2.0
8793
# via build
88-
pytest==8.3.5
94+
pytest==8.4.2
8995
# via
9096
# celerpy (pyproject.toml)
9197
# pytest-cov
9298
# pytest-pretty
93-
pytest-cov==6.1.0
99+
pytest-cov==7.0.0
94100
# via celerpy (pyproject.toml)
95-
pytest-pretty==1.2.0
101+
pytest-pretty==1.3.0
96102
# via celerpy (pyproject.toml)
97103
python-dateutil==2.9.0.post0
98104
# via matplotlib
99-
python-dotenv==1.1.0
105+
python-dotenv==1.1.1
100106
# via pydantic-settings
101-
pyyaml==6.0.2
107+
pyyaml==6.0.3
102108
# via pre-commit
103-
rich==14.0.0
109+
rich==14.1.0
104110
# via
105111
# pytest-pretty
106112
# typer
107-
ruamel-yaml==0.18.10
113+
ruamel-yaml==0.18.15
108114
# via
109115
# celerpy (pyproject.toml)
110116
# dapperdata
111-
ruff==0.11.4
117+
ruamel-yaml-clib==0.2.14
118+
# via ruamel-yaml
119+
ruff==0.13.3
112120
# via celerpy (pyproject.toml)
113121
shellingham==1.5.4
114122
# via typer
115123
six==1.17.0
116124
# via python-dateutil
117-
toml-sort==0.24.2
125+
toml-sort==0.24.3
118126
# via celerpy (pyproject.toml)
119-
tomlkit==0.13.2
127+
tomlkit==0.13.3
120128
# via toml-sort
121-
typer==0.15.2
129+
typer==0.19.2
122130
# via
123131
# celerpy (pyproject.toml)
124132
# dapperdata
125-
typing-extensions==4.13.1
133+
typing-extensions==4.15.0
126134
# via
127135
# mypy
128136
# pydantic
129137
# pydantic-core
130138
# typer
131139
# typing-inspection
132-
typing-inspection==0.4.0
133-
# via pydantic
134-
uv==0.6.12
140+
typing-inspection==0.4.2
141+
# via
142+
# pydantic
143+
# pydantic-settings
144+
uv==0.8.23
135145
# via celerpy (pyproject.toml)
136-
virtualenv==20.30.0
146+
virtualenv==20.34.0
137147
# via pre-commit

0 commit comments

Comments
 (0)