-
Notifications
You must be signed in to change notification settings - Fork 27
/
Copy pathanimation.py
256 lines (223 loc) · 8.63 KB
/
animation.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
import os
from pathlib import Path
import imageio
import numpy as np
from napari.utils.events import EventedList
from napari.utils.io import imsave
from scipy import ndimage as ndi
from .easing import Easing
from .interpolation import Interpolation, interpolate_state
from .key_frame import KeyFrame, ViewerState
from dataclasses import asdict
class Animation:
"""Make animations using the napari viewer.
Parameters
----------
viewer : napari.Viewer
napari viewer.
Attributes
----------
key_frames : list of dict
List of viewer state dictionaries.
frame : int
Currently shown key frame.
state_interpolation_map : dict
Dictionary relating state attributes to interpolation functions.
"""
def __init__(self, viewer):
self.viewer = viewer
self.key_frames: EventedList[KeyFrame] = EventedList()
self.frame: int = -1
self.state_interpolation_map = {
"camera.angles": Interpolation.SLERP,
"camera.zoom": Interpolation.LOG,
}
def capture_keyframe(
self, steps=15, ease=Easing.LINEAR, insert=True, frame: int = None
):
"""Record current key-frame
Parameters
----------
steps : int
Number of interpolation steps between last keyframe and captured one.
ease : callable, optional
If provided this method should make from `[0, 1]` to `[0, 1]` and will
be used as an easing function for the transition between the last state
and captured one.
insert : bool
If captured key-frame should insert into current list or replace the current
keyframe.
frame : int, optional
If provided use this value for frame rather than current frame number.
"""
if frame is not None:
self.frame = frame
new_state = KeyFrame.from_viewer(self.viewer, steps=steps, ease=ease)
if insert or self.frame == -1:
current_frame = self.frame
self.key_frames.insert(current_frame + 1, new_state)
self.frame = current_frame + 1
else:
self.key_frames[self.frame] = new_state
@property
def n_frames(self):
"""The total frame count of the animation"""
if len(self.key_frames) >= 2:
return np.sum([f.steps for f in self.key_frames[1:]]) + 1
else:
return 0
def set_to_keyframe(self, frame):
"""Set the viewer to a given key-frame
Parameters
-------
frame : int
Key-frame to visualize
"""
self.frame = frame
if len(self.key_frames) > 0 and self.frame > -1:
self._set_viewer_state(self.key_frames[frame].viewer_state)
def set_to_current_keyframe(self):
"""Set the viewer to the current key-frame"""
self._set_viewer_state(self.key_frames[self.frame].viewer_state)
def _set_viewer_state(self, state: ViewerState):
"""Sets the current viewer state
Parameters
----------
state : dict
Description of viewer state.
"""
self.viewer.camera.update(state.camera)
self.viewer.dims.update(state.dims)
self._set_layer_state(state.layers)
def _set_layer_state(self, layer_state: dict):
for layer_name, layer_state in layer_state.items():
layer = self.viewer.layers[layer_name]
for key, value in layer_state.items():
original_value = getattr(layer, key)
# Only set if value differs to avoid expensive redraws
if not np.array_equal(original_value, value):
setattr(layer, key, value)
def _state_generator(self):
self._validate_animation()
# iterate over and interpolate between pairs of key-frames
for current_frame, next_frame in zip(
self.key_frames, self.key_frames[1:]
):
# capture necessary info for interpolation
initial_state = current_frame.viewer_state
final_state = next_frame.viewer_state
interpolation_steps = next_frame.steps
ease = next_frame.ease
# generate intermediate states between key-frames
for interp in range(interpolation_steps):
fraction = interp / interpolation_steps
fraction = ease(fraction)
state = interpolate_state(
asdict(initial_state),
asdict(final_state),
fraction,
self.state_interpolation_map,
)
yield ViewerState(**state)
# be sure to include the final state
yield final_state
def _validate_animation(self):
if len(self.key_frames) < 2:
raise ValueError(
f"Must have at least 2 key frames, received {len(self.key_frames)}"
)
def _frame_generator(self, canvas_only=True):
for i, state in enumerate(self._state_generator()):
print("Rendering frame ", i + 1, "of", self.n_frames)
self._set_viewer_state(state)
frame = self.viewer.screenshot(canvas_only=canvas_only)
yield frame
@property
def current_key_frame(self):
return self.key_frames[self.frame]
def animate(
self,
path,
fps=20,
quality=5,
format=None,
canvas_only=True,
scale_factor=None,
):
"""Create a movie based on key-frames
Parameters
-------
path : str
path to use for saving the movie (can also be a path). Extension
should be one of .gif, .mp4, .mov, .avi, .mpg, .mpeg, .mkv, .wmv
If no extension is provided, images are saved as a folder of PNGs
interpolation_steps : int
Number of steps for interpolation.
fps : int
frames per second
quality: float
number from 1 (lowest quality) to 9
only applies to non-gif extensions
format: str
The format to use to write the file. By default imageio selects the appropriate for you based on the filename.
canvas_only : bool
If True include just includes the canvas, otherwise include the full napari viewer.
scale_factor : float
Rescaling factor for the image size. Only used without
viewer (with_viewer = False).
"""
self._validate_animation()
# create a frame generator
frame_gen = self._frame_generator(canvas_only=canvas_only)
# create path object
path_obj = Path(path)
folder_path = path_obj.absolute().parent.joinpath(path_obj.stem)
# if path has no extension, save as fold of PNG
save_as_folder = False
if path_obj.suffix == "":
save_as_folder = True
# try to create an ffmpeg writer. If not installed default to folder creation
if not save_as_folder:
try:
# create imageio writer. Handle separately imageio-ffmpeg extensions and
# gif extension which doesn't accept the quality parameter.
if path_obj.suffix in [
".mov",
".avi",
".mpg",
".mpeg",
".mp4",
".mkv",
".wmv",
]:
writer = imageio.get_writer(
path,
fps=fps,
quality=quality,
format=format,
)
else:
writer = imageio.get_writer(path, fps=fps, format=format)
except ValueError as err:
print(err)
print("Your file will be saved as a series of PNG files")
save_as_folder = True
if save_as_folder:
# if movie is saved as series of PNG, create a folder
if folder_path.is_dir():
for f in folder_path.glob("*.png"):
os.remove(f)
else:
folder_path.mkdir(exist_ok=True)
# save frames
for ind, frame in enumerate(frame_gen):
if scale_factor is not None:
frame = ndi.zoom(frame, (scale_factor, scale_factor, 1))
frame = frame.astype(np.uint8)
if not save_as_folder:
writer.append_data(frame)
else:
fname = folder_path / (path_obj.stem + "_" + str(ind) + ".png")
imsave(fname, frame)
if not save_as_folder:
writer.close()