Animation in napari#

Creating a short animation is a great way to introduce your data during a presentation and provide insight into the image analysis. It is especially valuable while working with 3D data that can otherwise be presented only in a form of 2D screenshots.

To grasp the process of creating an animation, it is essential to understand the concept of keyframes (https://en.wikipedia.org/wiki/Key_frame ). Keyframes can be likened to anchor points that steer an animation. Thus, by defining the keyframes, you determine what will be portrayed. The transitions between keyframes are smooth and are automatically filled in for you. Keyframes in a napari animation can capture such events as change of a frame, zoom, camera angle (3D projections) or even brightness of an image. The speed of the animation is controlled by the number of in-between frames (‘Steps’) inserted between the key frames.

Napari provides a simple way to create animations in both manual (interactive) and programmatic (scripting) way through the Animation Plugin.

In this tutorial we will create a simple animation of FIB-SEM data.

Setup#

# this cell is required to run these notebooks on Binder. Make sure that you also have a desktop tab open.
import os
if 'BINDER_SERVICE_HOST' in os.environ:
    os.environ['DISPLAY'] = ':1.0'
# Instal animation plugin
# This step has to be performed only once in a given environment.
# You can remove this cell or use '#' to comment the code out after a successful installation.

!pip install napari-animation

# No problem if you execute it again though, Python will just tell you 'Requirement already satisfied'.
import os
import zarr
import dask.array as da

import napari

from napari_animation import Animation
from napari_animation.easing import Easing

Reading in data#

Get data from OpenOrganelle.

group = zarr.open(store=zarr.N5FSStore('s3://janelia-cosem-datasets/jrc_mus-kidney/jrc_mus-kidney.n5', anon=True)) # access the root of the n5 container

# Get access to all resolution levels of the data.
img_data = [
    da.from_zarr(group[f'em/fibsem-uint8/s{i}'])
    for i in range(0, 4)
]

# Get label data for all resolution levels
label_data = [
    da.from_zarr(group[f'labels/empanada-mito_seg/s{i}'])
    for i in range(0, 4)
]

Get an example cube of the data and load it into memory.

cropped_img = img_data[3][50:150:2,400:500,1000:1100].compute()
print(cropped_img.shape)

# Note that the segmentations are downsampled by 2, so we need to use a different scale for labels
cropped_label = label_data[2][50:150:2,400:500,1000:1100].compute()
print(cropped_label.shape)
(50, 100, 100)
(50, 100, 100)

Visualize in napari#

viewer = napari.Viewer()
viewer.add_image(cropped_img)

Use animation plugin interactively#

  • From the upper menu choose Plugins - wizard (napari-animation)

  • With the bottom slides choose first slice #0

  • Click ‘Capture’ to set it as a first keyframe

  • Choose number of Steps to 30

  • Move slider to slice #40

  • Capture the keyframe

  • Move slider to slice #49

  • Choose number of Steps to 60

  • Capture the keyframe

  • Save your first animation

Use script to record an animation#

# define pathway to save your animation
save_path = 'my_animation_from_script.mp4'

Note

If you don’t specify a full pathway but only a name of the file it will be saved in your current working directory (cwd). If you don’t know where it is use os.getcwd()

animation = Animation(viewer)

viewer.reset_view()
viewer.dims.current_step = (0, 0, 0)
animation.capture_keyframe()

viewer.dims.current_step = (40, 0, 0)
animation.capture_keyframe(steps=40)

viewer.dims.current_step = (49, 0, 0)
animation.capture_keyframe(steps=70)

animation.animate(save_path, canvas_only=True, fps=24)
Rendering frames...
  0%|          | 0/111 [00:00<?, ?it/s]
IMAGEIO FFMPEG_WRITER WARNING: input image is not divisible by macro_block_size=16, resizing from (966, 922) to (976, 928) to ensure video compatibility with most codecs and players. To prevent resizing, make your input image divisible by the macro_block_size or set the macro_block_size to 1 (risking incompatibility).
  1%|          | 1/111 [00:00<00:11,  9.94it/s]
[swscaler @ 0x68e0bc0] Warning: data is not aligned! This can lead to a speed loss
  5%|▍         | 5/111 [00:00<00:04, 26.17it/s]
  9%|▉         | 10/111 [00:00<00:02, 34.39it/s]
 14%|█▎        | 15/111 [00:00<00:02, 38.09it/s]
 18%|█▊        | 20/111 [00:00<00:02, 40.23it/s]
 23%|██▎       | 25/111 [00:00<00:02, 41.44it/s]
 27%|██▋       | 30/111 [00:00<00:01, 41.57it/s]
 32%|███▏      | 35/111 [00:00<00:01, 42.11it/s]
 36%|███▌      | 40/111 [00:01<00:01, 42.32it/s]
 41%|████      | 45/111 [00:01<00:01, 40.59it/s]
 45%|████▌     | 50/111 [00:01<00:02, 29.47it/s]
 49%|████▊     | 54/111 [00:01<00:02, 28.00it/s]
 52%|█████▏    | 58/111 [00:01<00:01, 27.75it/s]
 55%|█████▍    | 61/111 [00:01<00:01, 26.36it/s]
 58%|█████▊    | 64/111 [00:01<00:01, 25.95it/s]
 60%|██████    | 67/111 [00:02<00:01, 24.26it/s]
 63%|██████▎   | 70/111 [00:02<00:01, 23.37it/s]
 66%|██████▌   | 73/111 [00:02<00:01, 22.20it/s]
 68%|██████▊   | 76/111 [00:02<00:01, 20.82it/s]
 71%|███████   | 79/111 [00:02<00:01, 21.45it/s]
 74%|███████▍  | 82/111 [00:02<00:01, 22.39it/s]
 77%|███████▋  | 85/111 [00:03<00:01, 19.86it/s]
 79%|███████▉  | 88/111 [00:03<00:01, 20.56it/s]
 82%|████████▏ | 91/111 [00:03<00:00, 21.74it/s]
 85%|████████▍ | 94/111 [00:03<00:00, 22.97it/s]
 88%|████████▊ | 98/111 [00:03<00:00, 25.23it/s]
 91%|█████████ | 101/111 [00:03<00:00, 25.21it/s]
 94%|█████████▎| 104/111 [00:03<00:00, 26.27it/s]
 97%|█████████▋| 108/111 [00:03<00:00, 27.92it/s]
100%|██████████| 111/111 [00:03<00:00, 27.96it/s]

Script from the community#

Check out this demo from the community that was collaboratively developed on the by Callum Tromans-Coia, Lorenzo Gaifas, and Alister Burt. For more details see https://forum.image.sc/t/creating-an-animation-for-visualisation-of-3d-labels-emerging-from-a-2d-plane/77517/6.

viewer = napari.Viewer(ndisplay=3)

image_layer = viewer.add_image(cropped_img, name="image", depiction="plane", blending='additive')
labels_layer = viewer.add_labels(cropped_label, name="labels")

viewer.camera.angles = (-18.23797054423494, 41.97404742075617, 141.96173085742896)
/opt/hostedtoolcache/Python/3.11.9/x64/lib/python3.11/site-packages/napari/plugins/_plugin_manager.py:560: UserWarning: Plugin 'napari_skimage_regionprops2' has already registered a function widget 'duplicate current frame' which has now been overwritten
  warn(message=warn_message)
animation = Animation(viewer)

labels_layer.visible = False
image_layer.plane.position = (0, 0, 0)
animation.capture_keyframe(steps=30)

image_layer.plane.position = (49, 0, 0)
animation.capture_keyframe(steps=30)

image_layer.plane.position = (0, 0, 0)

animation.capture_keyframe(steps=30)

# define a function to replace label data when viewer position changes
def replace_labels_data():
    z_cutoff = int(image_layer.plane.position[0])
    new_labels_data = cropped_label.copy()
    new_labels_data[z_cutoff:] = 0
    labels_layer.data = new_labels_data


image_layer.plane.events.position.connect(replace_labels_data)
labels_layer.visible = True
labels_layer.experimental_clipping_planes = [{
    "position": (0, 0, 0),
    "normal": (-1, 0, 0),  # point up in z (i.e: show stuff above plane)
}]

image_layer.plane.position = (49, 0, 0)
# access first plane, since it's a list
labels_layer.experimental_clipping_planes[0].position = (49, 0, 0)
animation.capture_keyframe(steps=30)

image_layer.plane.position = (0, 0, 0)
animation.capture_keyframe(steps=30)

animation.animate("animation_labels.mp4", canvas_only=True)
image_layer.plane.position = (0, 0, 0)
Rendering frames...
  0%|          | 0/121 [00:00<?, ?it/s]
IMAGEIO FFMPEG_WRITER WARNING: input image is not divisible by macro_block_size=16, resizing from (959, 565) to (960, 576) to ensure video compatibility with most codecs and players. To prevent resizing, make your input image divisible by the macro_block_size or set the macro_block_size to 1 (risking incompatibility).
[swscaler @ 0x5872bc0] Warning: data is not aligned! This can lead to a speed loss

  2%|▏         | 2/121 [00:00<00:07, 16.74it/s]
  4%|▍         | 5/121 [00:00<00:05, 19.95it/s]
  7%|▋         | 8/121 [00:00<00:05, 21.14it/s]
  9%|▉         | 11/121 [00:00<00:05, 21.72it/s]
 12%|█▏        | 14/121 [00:00<00:04, 21.90it/s]
 14%|█▍        | 17/121 [00:01<00:07, 14.39it/s]
 17%|█▋        | 20/121 [00:01<00:06, 16.35it/s]
 19%|█▉        | 23/121 [00:01<00:05, 17.96it/s]
 21%|██▏       | 26/121 [00:01<00:04, 19.24it/s]
 24%|██▍       | 29/121 [00:01<00:04, 20.17it/s]
 26%|██▋       | 32/121 [00:01<00:04, 20.85it/s]
 29%|██▉       | 35/121 [00:01<00:04, 21.38it/s]
 31%|███▏      | 38/121 [00:01<00:03, 21.64it/s]
 34%|███▍      | 41/121 [00:02<00:03, 21.91it/s]
 36%|███▋      | 44/121 [00:02<00:03, 21.63it/s]
 39%|███▉      | 47/121 [00:02<00:03, 21.77it/s]
 41%|████▏     | 50/121 [00:02<00:03, 21.84it/s]
 44%|████▍     | 53/121 [00:02<00:03, 21.98it/s]
 46%|████▋     | 56/121 [00:02<00:02, 22.18it/s]
 49%|████▉     | 59/121 [00:02<00:02, 22.28it/s]
 51%|█████     | 62/121 [00:03<00:04, 12.12it/s]
 53%|█████▎    | 64/121 [00:04<00:07,  7.40it/s]
 55%|█████▍    | 66/121 [00:04<00:09,  5.59it/s]
 56%|█████▌    | 68/121 [00:05<00:11,  4.69it/s]
 57%|█████▋    | 69/121 [00:05<00:11,  4.38it/s]
 58%|█████▊    | 70/121 [00:05<00:12,  4.11it/s]
 59%|█████▊    | 71/121 [00:06<00:12,  3.88it/s]
 60%|█████▉    | 72/121 [00:06<00:13,  3.71it/s]
 60%|██████    | 73/121 [00:06<00:13,  3.58it/s]
 61%|██████    | 74/121 [00:07<00:13,  3.46it/s]
 62%|██████▏   | 75/121 [00:07<00:13,  3.39it/s]
 63%|██████▎   | 76/121 [00:07<00:13,  3.34it/s]
 64%|██████▎   | 77/121 [00:08<00:13,  3.29it/s]
 64%|██████▍   | 78/121 [00:08<00:13,  3.27it/s]
 65%|██████▌   | 79/121 [00:08<00:12,  3.25it/s]
 66%|██████▌   | 80/121 [00:09<00:12,  3.24it/s]
 67%|██████▋   | 81/121 [00:09<00:12,  3.23it/s]
 68%|██████▊   | 82/121 [00:09<00:12,  3.22it/s]
 69%|██████▊   | 83/121 [00:09<00:11,  3.22it/s]
 69%|██████▉   | 84/121 [00:10<00:11,  3.21it/s]
 70%|███████   | 85/121 [00:10<00:11,  3.22it/s]
 71%|███████   | 86/121 [00:10<00:10,  3.22it/s]
 72%|███████▏  | 87/121 [00:11<00:10,  3.21it/s]
 73%|███████▎  | 88/121 [00:11<00:10,  3.22it/s]
 74%|███████▎  | 89/121 [00:11<00:09,  3.22it/s]
 74%|███████▍  | 90/121 [00:12<00:09,  3.21it/s]
 75%|███████▌  | 91/121 [00:12<00:09,  3.17it/s]
 76%|███████▌  | 92/121 [00:13<00:11,  2.53it/s]
 77%|███████▋  | 93/121 [00:13<00:12,  2.25it/s]
 78%|███████▊  | 94/121 [00:14<00:12,  2.09it/s]
 79%|███████▊  | 95/121 [00:14<00:13,  1.99it/s]
 79%|███████▉  | 96/121 [00:15<00:13,  1.92it/s]
 80%|████████  | 97/121 [00:15<00:12,  1.88it/s]
 81%|████████  | 98/121 [00:16<00:12,  1.85it/s]
 82%|████████▏ | 99/121 [00:16<00:12,  1.83it/s]
 83%|████████▎ | 100/121 [00:17<00:11,  1.82it/s]
 83%|████████▎ | 101/121 [00:18<00:11,  1.81it/s]
 84%|████████▍ | 102/121 [00:18<00:10,  1.80it/s]
 85%|████████▌ | 103/121 [00:19<00:10,  1.80it/s]
 86%|████████▌ | 104/121 [00:19<00:09,  1.79it/s]
 87%|████████▋ | 105/121 [00:20<00:08,  1.79it/s]
 88%|████████▊ | 106/121 [00:20<00:08,  1.79it/s]
 88%|████████▊ | 107/121 [00:21<00:07,  1.78it/s]
 89%|████████▉ | 108/121 [00:22<00:07,  1.78it/s]
 90%|█████████ | 109/121 [00:22<00:06,  1.78it/s]
 91%|█████████ | 110/121 [00:23<00:06,  1.78it/s]
 92%|█████████▏| 111/121 [00:23<00:05,  1.78it/s]
 93%|█████████▎| 112/121 [00:24<00:05,  1.79it/s]
 93%|█████████▎| 113/121 [00:24<00:04,  1.79it/s]
 94%|█████████▍| 114/121 [00:25<00:03,  1.79it/s]
 95%|█████████▌| 115/121 [00:25<00:03,  1.78it/s]
 96%|█████████▌| 116/121 [00:26<00:02,  1.78it/s]
 97%|█████████▋| 117/121 [00:27<00:02,  1.78it/s]
 97%|█████████▋| 117/121 [00:27<00:00,  4.24it/s]

---------------------------------------------------------------------------
KeyboardInterrupt                         Traceback (most recent call last)
Cell In[10], line 37
     34 image_layer.plane.position = (0, 0, 0)
     35 animation.capture_keyframe(steps=30)
---> 37 animation.animate("animation_labels.mp4", canvas_only=True)
     38 image_layer.plane.position = (0, 0, 0)

File /opt/hostedtoolcache/Python/3.11.9/x64/lib/python3.11/site-packages/napari_animation/animation.py:237, in Animation.animate(self, filename, fps, quality, file_format, canvas_only, scale_factor)
    235 sleep(0.05)
    236 with tqdm(total=n_frames) as pbar:
--> 237     for frame_index, image in enumerate(frame_generator):
    238         if save_as_folder is True:
    239             frame_filename = (
    240                 folder_path / f"{file_path.stem}_{frame_index:06d}.png"
    241             )

File /opt/hostedtoolcache/Python/3.11.9/x64/lib/python3.11/site-packages/napari_animation/frame_sequence.py:153, in FrameSequence.iter_frames(self, viewer, canvas_only, scale_factor)
    151 """Iterate over interpolated viewer states, and yield rendered frames."""
    152 for _i, state in enumerate(self):
--> 153     frame = state.render(viewer, canvas_only=canvas_only)
    154     if scale_factor not in (None, 1):
    155         from scipy import ndimage as ndi

File /opt/hostedtoolcache/Python/3.11.9/x64/lib/python3.11/site-packages/napari_animation/viewer_state.py:85, in ViewerState.render(self, viewer, canvas_only)
     69 """Render this ViewerState to an image.
     70 
     71 Parameters
   (...)
     82     An RGBA image of shape (h, w, 4).
     83 """
     84 self.apply(viewer)
---> 85 return viewer.screenshot(canvas_only=canvas_only, flash=False)

File /opt/hostedtoolcache/Python/3.11.9/x64/lib/python3.11/site-packages/napari/viewer.py:182, in Viewer.screenshot(self, path, size, scale, canvas_only, flash)
    146 def screenshot(
    147     self,
    148     path: Optional[str] = None,
   (...)
    153     flash: bool = True,
    154 ):
    155     """Take currently displayed screen and convert to an image array.
    156 
    157     Parameters
   (...)
    180         upper-left corner of the rendered region.
    181     """
--> 182     return self.window.screenshot(
    183         path=path,
    184         size=size,
    185         scale=scale,
    186         flash=flash,
    187         canvas_only=canvas_only,
    188     )

File /opt/hostedtoolcache/Python/3.11.9/x64/lib/python3.11/site-packages/napari/_qt/qt_main_window.py:1783, in Window.screenshot(self, path, size, scale, flash, canvas_only)
   1752 def screenshot(
   1753     self, path=None, size=None, scale=None, flash=True, canvas_only=False
   1754 ):
   1755     """Take currently displayed viewer and convert to an image array.
   1756 
   1757     Parameters
   (...)
   1780         upper-left corner of the rendered region.
   1781     """
-> 1783     img = QImg2array(self._screenshot(size, scale, flash, canvas_only))
   1784     if path is not None:
   1785         imsave(path, img)

File /opt/hostedtoolcache/Python/3.11.9/x64/lib/python3.11/site-packages/napari/_qt/qt_main_window.py:1687, in Window._screenshot(self, size, scale, flash, canvas_only, fit_to_data_extent)
   1685     self._qt_viewer.viewer.reset_view(margin=0)
   1686 try:
-> 1687     img = canvas.screenshot()
   1688     if flash:
   1689         add_flash_animation(self._qt_viewer._welcome_widget)

File /opt/hostedtoolcache/Python/3.11.9/x64/lib/python3.11/site-packages/napari/_vispy/canvas.py:676, in VispyCanvas.screenshot(self)
    674 def screenshot(self) -> QImage:
    675     """Return a QImage based on what is shown in the viewer."""
--> 676     return self.native.grabFramebuffer()

KeyboardInterrupt: