Skip to content

Commit 5b0fdc8

Browse files
animation helpers (#1229)
* roughly working * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update __init__.py * test h5py pin * relax h5py version requirement * Update _animate.py more documentation (still unfinished) tweak some argument handling * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update _animate.py * refactor * Create test_animate.py * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update test_animate.py * back_and_forth * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * remove snake, update docstrings * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * animate_interact2D: tests and docs * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * animate_quick2D: working example * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * update docs * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update _quick.py * Update _helpers.py * quick_v2 test submodule make quick plotters as iterators animate_quick2D becomes a trivial wrapper * Update test_animate.py * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * entry point basic functionality * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * quick2D v2 -> quick2Ds * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * propagate quicknDs name changes * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update test_animate.py * expose Quick2D class * refactor * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * quick2Ds: saving figures works * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update _util.py * revert _quick v1 changes * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * testing quick2D refactor * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update _2D.py * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update test_animate.py * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * begin quick1D refactor * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update _2D.py * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update _1D.py * new quick1D in tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * refine 1D plotting * Update _1D.py * Update _1D.py * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * quick1D animate test * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * animate_quick2D -> animate_quick * refine docs, typing remove _quick1D as it is not used in tests * fully replace _quick with new implementation * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update _animate.py * Update test_quick2D.py * add verbose back in, but deprecated * Update test_quick1D.py * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * remove _quick2D replace this wrapper with a flag * small doc revision * Update CHANGELOG.md * small code cleanups * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * quick1D shows channel label * don't use plt.sca pyplot needs to manage the plots for animations * animate2D: plot specified channel * animate2D respects axis orders * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update _animate.py * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix quick1D bugs remove initial blank figure calculated nfigs handles reduced channel size (that is not caused by constants) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent ed26904 commit 5b0fdc8

15 files changed

Lines changed: 1032 additions & 409 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
55

66
## [Unreleased]
77

8+
### Added
9+
- new artists submodule `animate` as a convenient wrapper for matplotlib's `FuncAnimate`
10+
- `animate.animate2D`: allows versatile conversion of data to animation.
11+
- `animate.animate_interact2D`: create an animation from an interact2D object.
12+
- `Quick2DIterator`, `Quick1DIterator`: iterator classes for making figures for each object in a data chop.
13+
- `quick2Ds` and `quick1Ds`: like `quick2D` and `quick1D`, but wrap the iterator classes.
14+
- `animate.animate_quick`: create an animation whose frames are the figures that would be created in a quick1Ds/quick2Ds call.
15+
16+
### Changed
17+
- `interact2D`: replaced SimpleNamespace object with a dataclass for more explicit typing
18+
- `quick1D`, `quick2D`: refactored for integration with iterators, animations (these functions are wrappers for the class `Quick1DLegacy`, `Quick2DLegacy`).
19+
820
### Fixed
921
- `Data.squeeze`: axes of output object now inherit units from axes of the input object
1022

WrightTools/artists/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
# flake8: noqa
44

5-
5+
from ._animate import *
66
from ._base import *
77
from ._colors import *
88
from ._helpers import *
9-
from ._quick import *
109
from ._interact import *
10+
from ._quick import *

WrightTools/artists/_animate.py

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"""helpers for making animations"""
2+
3+
import matplotlib.pyplot as plt
4+
import numpy as np
5+
import logging
6+
7+
from functools import partial
8+
from matplotlib.animation import FuncAnimation
9+
from inspect import isclass
10+
11+
from ._helpers import norm_from_channel
12+
from ._interact import interact2D_fig
13+
from ._quick import Quick1DIterator, Quick2DIterator
14+
from ..kit import joint_shape
15+
16+
__all__ = ["animate2D", "animate_interact2D", "animate_quick"]
17+
logger = logging.getLogger("animation")
18+
19+
20+
def animate2D(
21+
data,
22+
norm=None,
23+
channel=0,
24+
cmap=None,
25+
back_and_forth: bool = False,
26+
**ani_kwargs,
27+
) -> FuncAnimation:
28+
"""
29+
animate pcolormesh of a nd dataset (ndim >=2)
30+
mesh plots last two axes of the dataset (use `Data.transform` if needed)
31+
32+
Parameters
33+
----------
34+
35+
data: WrightTools.data
36+
dataset to animate. take the last two axes as the ones that are plotted;
37+
other axes compose the frames of the animation
38+
39+
norm: Normalize instance or callable
40+
determines the normalization rules to follow.
41+
If channel is signed, defaults to CenteredNorm with null center.
42+
If channel is unsigned, defaults to Normalize from null to max.
43+
44+
channel: string, index, or Channel
45+
Select which channel to plot
46+
47+
cmap: str or Colormap (optional)
48+
colormap used. Defaults to WrightTools default
49+
50+
back_and_forth: bool = False
51+
when True, the animation will go in reverse after going forward,
52+
creating a continuous loop when repeat is on
53+
54+
**kwargs: dict items
55+
all extra kwargs are passed to matplotlib.FuncAnimation
56+
57+
Returns
58+
-------
59+
60+
animation: matplotlib.animation.animation
61+
62+
Example
63+
-------
64+
General usage (create an animation procedure and then write to file):
65+
```
66+
norm=CenteredNorm(vcenter=0, halfrange=np.abs(d.channels[0][:]).max())
67+
ani = wt.artists.animate2D(
68+
d, cmap="bwr", norm=norm, interval=100
69+
)
70+
```
71+
The animation has write to file utilities like `to_html5_video`:
72+
```
73+
with open(f"{d.natural_name}_animation.html", "w") as f:
74+
f.write(ani.to_html5_video())
75+
```
76+
Alternatively, you can show in the interactive viewer and watch the animation:
77+
```
78+
plt.show()
79+
```
80+
For colorbar normalized at each frame, you can use `functools.partial`:
81+
```
82+
from functools import partial
83+
norm = partial(CenteredNorm, vcenter=0) # halfrange evaluated for each frame
84+
```
85+
"""
86+
87+
channel = data.get_channel(channel)
88+
if norm is None:
89+
norm = norm_from_channel(channel)
90+
if cmap is None:
91+
cmap = "signed" if channel.signed else "default"
92+
# detect whether to call norm each frame
93+
# probably not an optimal implementation, but working for now
94+
call_norm = isclass(norm) or isinstance(norm, partial)
95+
96+
def gen_title(ind):
97+
parts = [
98+
f"{var.natural_name} = {var[:][ind].squeeze():.2f} {var.units}"
99+
for var in map(lambda a: a.variables[0], data.axes[:-2])
100+
]
101+
return "\n".join(parts)
102+
103+
frame_shape = joint_shape(*[a[:] for a in data.axes[:-2]])
104+
channel_shape = joint_shape(*[a[:] for a in data.axes[-2:]])
105+
# mask indices that are spanned by the x and y axes
106+
mask = [ci > fi for ci, fi in zip(channel_shape, frame_shape)]
107+
logger.debug(f"{frame_shape=}, {channel_shape=}, {mask=}")
108+
109+
fig, ax = plt.subplots(subplot_kw=dict(projection="wright"), dpi=140, layout="constrained")
110+
art = ax.pcolormesh(
111+
data[tuple([0 for i in data.shape[:-2]])],
112+
cmap=cmap,
113+
norm=norm() if call_norm else norm,
114+
)
115+
colorbar = fig.colorbar(art, ax=ax)
116+
colorbar.set_label(channel.label)
117+
118+
ax.set_title(gen_title(tuple([0 for _ in data.shape[:-2]])))
119+
# with layout well set, turn off the engine (avoids jittering frames)
120+
fig.set_layout_engine("none")
121+
122+
def updater(frame):
123+
frame = tuple(slice(None) if mi else fi for fi, mi in zip(frame, mask))
124+
logger.info(f"{frame=}")
125+
art.set_array(channel[frame])
126+
ax.set_title(gen_title(frame))
127+
art.set_norm(norm() if call_norm else norm)
128+
fig.canvas.draw_idle()
129+
return art
130+
131+
# generate frame sequence
132+
133+
frames = list(np.ndindex(frame_shape))
134+
if back_and_forth:
135+
frames += reversed(frames)
136+
137+
return FuncAnimation(
138+
fig=fig,
139+
func=updater,
140+
frames=frames,
141+
**ani_kwargs,
142+
)
143+
144+
145+
def animate_quick(q2d: Quick1DIterator | Quick2DIterator, **kwargs) -> FuncAnimation:
146+
"""
147+
animate a quick2Ds series
148+
149+
unlike other animation functions, this enforces repeat=False
150+
151+
Parameters
152+
----------
153+
154+
**kwargs: dict items
155+
all extra kwargs are passed to matplotlib.FuncAnimation
156+
157+
158+
Example
159+
-------
160+
```python
161+
quick_iter = wt.artists.quick1Ds(data, autosave=False, local=False)
162+
ani = wt.artists.animate_quick(quick_iter, interval=100)
163+
```
164+
165+
"""
166+
167+
return FuncAnimation(fig=q2d.fig, func=lambda x: None, frames=q2d, **kwargs)
168+
169+
170+
def animate_interact2D(
171+
interact2D: interact2D_fig, back_and_forth=False, **kwargs
172+
) -> FuncAnimation:
173+
"""
174+
Take an interact2D figure and create an animation by moving the sliders.
175+
176+
Parameters
177+
----------
178+
interact2D: interact2D_fig
179+
the output of an interact2D call
180+
181+
back_and_forth: bool = False
182+
when True, the animation will go in reverse after going forward,
183+
creating a continuous steps of variables when repeat is on
184+
185+
**kwargs: dict items
186+
all extra kwargs are passed to matplotlib.FuncAnimation
187+
188+
Example
189+
-------
190+
```python
191+
interactive = wt.artists.interact2D(data, local=True)
192+
ani = wt.artists.animate_interact2D(interactive, back_and_forth=True, interval=500)
193+
```
194+
"""
195+
196+
def update(frame):
197+
logger.info(f"{frame=}")
198+
for ind, slider in zip(frame, interact2D.sliders.values()):
199+
slider.set_val(ind)
200+
201+
frames = list(np.ndindex(tuple([s.valmax + 1 for s in interact2D.sliders.values()])))
202+
if back_and_forth:
203+
frames += reversed(frames)
204+
205+
return FuncAnimation(fig=interact2D.fig, func=update, frames=frames, **kwargs)

WrightTools/artists/_base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ def _apply_labels(
5656
xlabel = data.axes[0].label
5757
if autolabel in ["xy", "both", "y"] and not ylabel:
5858
if data.ndim == 1:
59-
ylabel = data.channels[channel_index].label
59+
channel = data.channels[channel_index]
60+
ylabel = channel.label if channel.label else channel.natural_name
6061
elif data.ndim == 2:
6162
ylabel = data.axes[1].label
6263
# apply

WrightTools/artists/_helpers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def _title(fig, title, subtitle="", *, margin=1, fontsize=20, subfontsize=18):
7474
height = fig.get_figheight() # inches
7575
distance = margin / 2.0 # distance from top of plot, in inches
7676
ratio = 1 - distance / height
77-
fig.text(0.5, ratio, subtitle, fontsize=subfontsize, ha="center", va="top")
77+
return fig.text(0.5, ratio, subtitle, fontsize=subfontsize, ha="center", va="top")
7878

7979

8080
def add_sideplot(

WrightTools/artists/_interact.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,29 @@
44
import matplotlib as mpl
55
import matplotlib.pyplot as plt
66
from matplotlib.widgets import Slider, RadioButtons
7+
from typing import Any
78
from types import SimpleNamespace
89

10+
from dataclasses import dataclass
11+
912
from ._helpers import create_figure, plot_colorbar, add_sideplot
1013
from ._base import _order_for_imshow
1114
from ._colors import colormaps
1215
from ..exceptions import DimensionalityError
1316
from .. import kit as wt_kit
1417
from .. import data as wt_data
1518

16-
__all__ = ["interact2D"]
19+
__all__ = ["interact2D", "interact2D_fig"]
20+
21+
22+
@dataclass
23+
class interact2D_fig:
24+
fig: plt.figure
25+
image: Any
26+
sliders: dict[str, Slider]
27+
crosshairs: list
28+
radio: RadioButtons
29+
cax: plt.axes
1730

1831

1932
class Focus:
@@ -206,7 +219,7 @@ def interact2D(
206219
207220
Returns
208221
-------
209-
out : types.SimpleNamespace
222+
out : wt.artists.interact2D_fig
210223
container for important interactive elements of the plot.
211224
212225
Properties
@@ -583,11 +596,11 @@ def update_key_press(info):
583596
for slider in sliders.values():
584597
slider.on_changed(update_slider)
585598

586-
out = SimpleNamespace(
599+
return interact2D_fig(
600+
fig=fig,
587601
image=obj2D,
588602
sliders=sliders,
589603
crosshairs=[crosshair_hline, crosshair_vline],
590604
radio=radio,
591-
colorbar=colorbar,
605+
cax=cax,
592606
)
593-
return out

0 commit comments

Comments
 (0)