Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

2. Python, Data, and Metadata

Goal: Control napari entirely from Python — create viewers, add layers, adjust properties, set physical scales and units, load data from files and the cloud with xarray and Zarr, and write your first analysis function.

1. Create a viewer from Python (10 min)

In Block 1 we explored the napari GUI. Now let’s do everything from code. Launching napari.Viewer() from a Jupyter notebook or Python script opens the napari GUI window — you can interact with it via the GUI and programmatically at the same time.

import napari

# Create an empty viewer
viewer = napari.Viewer()

Let’s load the Cells (3D + 2Ch) sample dataset and add it as layers — the same data we explored in Block 1, but now we’re doing it from Python.

from skimage.data import cells3d

image_data = cells3d()  # shape (60, 2, 256, 256) — (z, channels, y, x)
print(f'Data shape: {image_data.shape}')
Data shape: (60, 2, 256, 256)

Split the channels and add them with different colormaps:

membrane_data = image_data[:, 0, :, :]
nuclei_data = image_data[:, 1, :, :]

membrane = viewer.add_image(
    membrane_data,
    name='membranes',
    colormap='yellow',
)
nuclei = viewer.add_image(
    nuclei_data,
    name='nuclei',
    colormap='cyan',
    blending='additive',
)

2. Screenshots in your notebook (3 min)

Just like in the GUI, you can capture what’s on screen — but from code:

Loading...

3. Exercise: Layer controls from Python (5 min)

Every property you adjusted with sliders and dropdowns in the GUI can be set from Python. Try adjusting the nuclei layer:

nuclei_layer = viewer.layers['nuclei']
nuclei_layer.opacity = 0.9
nuclei_layer.contrast_limits = (0, 20000)
nuclei_layer.colormap = 'magenta'
Loading...
# Reset for next section
nuclei_layer.colormap = 'cyan'
nuclei_layer.contrast_limits = (0, 65535)
nuclei_layer.opacity = 1.0

4. Physical scale, units, and axis labels (10 min)

Images from microscopes and other instruments have physical meaning — pixels correspond to real-world distances. napari can represent this with scale, units, and axis labels.

Setting layer scale

napari handles units and physical scale on a per-layer and axis basis and uses Pint for parsing units. Read more about napari’s unit rendering in the scale and unit aware rendering guide.

for layer in viewer.layers:
    layer.scale = [0.13, 0.13, 0.13]
    layer.units = ('µm', 'µm', 'micrometer')

viewer.fit_to_view()  # fit the extent of all the layers to the canvas

Now enable the scale bar to see the physical scale:

viewer.scale_bar.visible = True
viewer.dims.point = (3, 0, 0)  # the set point of the dims slider is relative to the scale of your data
Loading...

Axis labels

The dimension sliders at the bottom of the viewer show generic index labels by default. We can rename them to reflect the actual axes and show the floating axes overlay:

viewer.dims.axis_labels = ['Z', 'Y', 'X']
viewer.floating_axes.visible = True

The napari-metadata plugin

The napari-metadata plugin provides a dock widget for viewing and editing all of this metadata in one place. Install it via Plugins > Install/Uninstall Plugins… and open it from Plugins > napari-metadata: Layer metadata.

The widget shows three sections:

  1. File metadata — read-only properties (shape, dtype, file path)

  2. Axes metadata — editable axis labels, scale, translation, and units

  3. Copy metadata — propagate metadata from one layer to others

5. Loading data with Python (10 min)

napari’s drag-and-drop and File > Open work well for many formats, but when you need precise control over data loading, you can use Python libraries directly.

Let’s switch to a new dataset — we’ll work with images of cell nuclei and fluorescent spots from an in situ sequencing experiment. These files are included in the workshop data.

from skimage.io import imread
from pathlib import Path

# Cross-environment path: works in both MyST (CWD=docs/) and JupyterLab
data_dir = next(p for p in [Path('extend/data'), Path('data')] if p.exists())

nuclei = imread(data_dir / 'nuclei_cropped.tif')
spots = imread(data_dir / 'spots_cropped.tif')

print(f'Nuclei shape: {nuclei.shape}')
print(f'Spots shape: {spots.shape}')
Nuclei shape: (492, 494)
Spots shape: (492, 494)

Let’s clear the viewer and add this new data:

viewer.layers.clear()

viewer.add_image(
    nuclei,
    colormap='I Forest'
)
viewer.add_image(
    spots,
    colormap='I Orange',
    blending='minimum'
)
<Image layer 'spots' at 0x7f3044104f50>
Loading...

Other image reading libraries

For multi-page TIFF, OME-TIFF, and other complex TIFF variants, tifffile provides more control:

from tifffile import imread
nuclei = imread(data_dir / 'nuclei_cropped.tif')

6. Zarr and OME-Zarr: cloud-native image data (10 min)

Zarr is a chunked, compressed, n-dimensional array format designed for cloud storage. Instead of downloading the whole file, you can stream only the parts you need.

OME-Zarr is a standardized specification for bioimaging data built on top of Zarr — it’s what the uses to host thousands of public microscopy images.

Opening a remote OME-Zarr image

We will first use the specialized napari-ome-zarr plugin to open public OME-Zarr datasets directly from the Image Data Resource (IDR). This plugin is not included in the default napari installation, but the extend feature includes napari-ome-zarr.

Now let’s stream a public image from the IDR Catalog of OME-NGFF samples.

# plants: https://livingobjects.ebi.ac.uk/idr/zarr/v0.5/idr0157/Asterella%20gracilis%20SWE/IMG_1033-1112%20Asterella%20gracilis%20(Mannia%20gracilis)%20stature.ome.zarr
# brain slice: https://livingobjects.ebi.ac.uk/idr/zarr/v0.4/idr0048A/9846152.zarr/
# cells: https://livingobjects.ebi.ac.uk/idr/zarr/v0.4/idr0047A/4496763.zarr

# If the connection is slow, try this fallback URL:
# https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.5/idr0062A/6001240_labels.zarr

zarr_url = "https://livingobjects.ebi.ac.uk/idr/zarr/v0.4/idr0048A/9846152.zarr/"

viewer_zarr = napari.Viewer()
viewer_zarr.open(zarr_url, plugin='napari-ome-zarr')
Loading...

Explore more OME-Zarr datasets

7. Full-circle: from plugin to code to napari (10 min)

To wrap this all up, let’s now use bioio to see how we can programmatically interact with a broad number of bioimaging formats. By default, the extend environment install bioio-ome-zarr and bioio-ome-tiff, but there are many other bioio plugins available.

Then, we’ll use ndevio as a flexible napari plugin that uses bioio and its metadata system to make napari-ready data, in addition to its general use as a napari reader plugin.

Under the hood, bioio uses xarray to represent image data with named dimensions — that’s what gives us meaningful axis labels like T, C, Z, Y, X instead of opaque index numbers.

We’re going to look at a multiscale chicken embryo:

from bioio import BioImage
import bioio

# note the trailing forward slash must be absent
img = BioImage("https://livingobjects.ebi.ac.uk/idr/zarr/v0.5/idr0066/ExpD_chicken_embryo_MIP.ome.zarr")

print(img.dims)
print(img.shape)
img.xarray_dask_data
<Dimensions [T: 1, C: 1, Z: 1, Y: 8978, X: 6510]>
(1, 1, 1, 8978, 6510)
Loading...
from ndevio import nImage

nimg = nImage("https://livingobjects.ebi.ac.uk/idr/zarr/v0.5/idr0066/ExpD_chicken_embryo_MIP.ome.zarr")
# sublcasses BioImage, so it contains all properties:
print(nimg.dims)
# and ndevio logic for "reasonable" defaults for napari
nimg.reference_xarray
<Dimensions [T: 1, C: 1, Z: 1, Y: 8978, X: 6510]>
Loading...
# the 0th data is the highest resolution, while the -1th data is the coursest
nimg.layer_data[-1]
Loading...
ldts = nimg.get_layer_data_tuples()
print(type(ldts))
ldts[0]
<class 'list'>
([dask.array<getitem, shape=(8978, 6510), dtype=uint8, chunksize=(256, 256), chunktype=numpy.ndarray>, dask.array<getitem, shape=(4489, 3255), dtype=uint8, chunksize=(256, 256), chunktype=numpy.ndarray>, dask.array<getitem, shape=(2244, 1627), dtype=uint8, chunksize=(256, 256), chunktype=numpy.ndarray>, dask.array<getitem, shape=(1122, 813), dtype=uint8, chunksize=(256, 256), chunktype=numpy.ndarray>, dask.array<getitem, shape=(561, 406), dtype=uint8, chunksize=(256, 256), chunktype=numpy.ndarray>, dask.array<getitem, shape=(280, 203), dtype=uint8, chunksize=(256, 203), chunktype=numpy.ndarray>, dask.array<getitem, shape=(140, 101), dtype=uint8, chunksize=(140, 101), chunktype=numpy.ndarray>, dask.array<getitem, shape=(70, 50), dtype=uint8, chunksize=(70, 50), chunktype=numpy.ndarray>], {'name': 'Channel:/:0 :: 0 :: / :: ExpD_chicken_embryo_MIP.ome', 'metadata': {'bioimage': <BioImage [plugin: bioio-ome-zarr, image-in-memory: False]>, 'raw_image_metadata': GroupMetadata(attributes={'ome': {'version': '0.5', '_creator': {'name': 'ome2024-ngff-challenge', 'version': '1.0.2', 'notes': None}, 'multiscales': [{'axes': [{'name': 'y', 'type': 'space', 'unit': 'micrometer'}, {'name': 'x', 'type': 'space', 'unit': 'micrometer'}], 'datasets': [{'coordinateTransformations': [{'scale': [1.6, 1.6], 'type': 'scale'}], 'path': '0'}, {'coordinateTransformations': [{'scale': [3.2, 3.2], 'type': 'scale'}], 'path': '1'}, {'coordinateTransformations': [{'scale': [6.4, 6.4], 'type': 'scale'}], 'path': '2'}, {'coordinateTransformations': [{'scale': [12.8, 12.8], 'type': 'scale'}], 'path': '3'}, {'coordinateTransformations': [{'scale': [25.6, 25.6], 'type': 'scale'}], 'path': '4'}, {'coordinateTransformations': [{'scale': [51.2, 51.2], 'type': 'scale'}], 'path': '5'}, {'coordinateTransformations': [{'scale': [102.4, 102.4], 'type': 'scale'}], 'path': '6'}, {'coordinateTransformations': [{'scale': [204.8, 204.8], 'type': 'scale'}], 'path': '7'}], 'name': '/'}], 'omero': {'channels': [{'active': True, 'coefficient': 1.0, 'color': 'FFFFFF', 'family': 'linear', 'inverted': False, 'label': 'Cy3', 'window': {'end': 55.0, 'max': 255.0, 'min': 0.0, 'start': 0.0}}], 'id': 1, 'rdefs': {'defaultT': 0, 'defaultZ': 0, 'model': 'greyscale'}}}}, zarr_format=3, consolidated_metadata=None, node_type='group'), 'ome_metadata': OME(images=[<1 field_type>])}, 'scale': (1.6, 1.6), 'axis_labels': ('Y', 'X'), 'units': (<Unit('micrometer')>, <Unit('micrometer')>), 'colormap': 'gray', 'blending': 'translucent_no_depth'}, 'image')
viewer.layers.clear()
for data, kwargs, _layer_type in nimg.get_layer_data_tuples():
    # add_method = getattr(viewer, f'add_{layer_type}')
    # add_method(data, **kwargs)
    viewer.add_image(data, **kwargs)
WARNING: Error drawing visual <vispy.visuals.mesh.MeshVisual object at 0x7f308c36fce0>
---------------------------------------------------------------------------
GLError                                   Traceback (most recent call last)
File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/vispy/app/backends/_qt.py:1000, in CanvasBackendDesktop.paintGL(self)
    998 # (0, 0, self.width(), self.height()))
    999 self._vispy_canvas.set_current()
-> 1000 self._vispy_canvas.events.draw(region=None)
   1002 # Clear the alpha channel with QOpenGLWidget (Qt >= 5.4), otherwise the
   1003 # window is translucent behind non-opaque objects.
   1004 # Reference:  MRtrix3/mrtrix3#266
   1005 if QT5_NEW_API or PYSIDE6_API or PYQT6_API:

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/vispy/util/event.py:453, in EventEmitter.__call__(self, *args, **kwargs)
    450 if self._emitting > 1:
    451     raise RuntimeError('EventEmitter loop detected!')
--> 453 self._invoke_callback(cb, event)
    454 if event.blocked:
    455     break

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/vispy/util/event.py:471, in EventEmitter._invoke_callback(self, cb, event)
    469     cb(event)
    470 except Exception:
--> 471     _handle_exception(self.ignore_callback_errors,
    472                       self.print_callback_errors,
    473                       self, cb_event=(cb, event))

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/vispy/util/event.py:469, in EventEmitter._invoke_callback(self, cb, event)
    467 def _invoke_callback(self, cb, event):
    468     try:
--> 469         cb(event)
    470     except Exception:
    471         _handle_exception(self.ignore_callback_errors,
    472                           self.print_callback_errors,
    473                           self, cb_event=(cb, event))

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/vispy/scene/canvas.py:226, in SceneCanvas.on_draw(self, event)
    223 # Now that a draw event is going to be handled, open up the
    224 # scheduling of further updates
    225 self._update_pending = False
--> 226 self._draw_scene()

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/vispy/scene/canvas.py:285, in SceneCanvas._draw_scene(self, bgcolor)
    283     bgcolor = self._bgcolor
    284 self.context.clear(color=bgcolor, depth=True)
--> 285 self.draw_visual(self.scene)

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/napari/_vispy/canvas.py:107, in NapariSceneCanvas.draw_visual(self, visual, event)
    105 def draw_visual(self, visual, event=None):
    106     try:
--> 107         super().draw_visual(visual, event=event)
    108     except RuntimeError as e:
    109         error_msg = e.args[0] if e.args else ''

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/vispy/scene/canvas.py:323, in SceneCanvas.draw_visual(self, visual, event)
    321         else:
    322             if hasattr(node, 'draw'):
--> 323                 node.draw()
    324                 prof.mark(str(node))
    325 else:

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/vispy/scene/visuals.py:106, in VisualNode.draw(self)
    104 if self.picking and not self.interactive:
    105     return
--> 106 self._visual_superclass.draw(self)

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/vispy/visuals/visual.py:668, in CompoundVisual.draw(self)
    666 for v in self._subvisuals:
    667     if v.visible:
--> 668         v.draw()

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/vispy/visuals/visual.py:514, in Visual.draw(self)
    512 self._configure_gl_state()
    513 try:
--> 514     self._program.draw(self._vshare.draw_mode,
    515                        self._vshare.index_buffer)
    516 except Exception:
    517     logger.warning("Error drawing visual %r" % self)

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/vispy/visuals/shaders/program.py:102, in ModularProgram.draw(self, *args, **kwargs)
    100 self.build_if_needed()
    101 self.update_variables()
--> 102 Program.draw(self, *args, **kwargs)

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/vispy/gloo/program.py:544, in Program.draw(self, mode, indices, check_error)
    540     raise TypeError("Invalid index: %r (must be IndexBuffer)" %
    541                     indices)
    543 # Process GLIR commands
--> 544 canvas.context.flush_commands()

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/vispy/gloo/context.py:172, in GLContext.flush_commands(self, event)
    170         fbo = 0
    171     self.shared.parser.parse([('CURRENT', 0, fbo)])
--> 172 self.glir.flush(self.shared.parser)

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/vispy/gloo/glir.py:584, in GlirQueue.flush(self, parser)
    582 def flush(self, parser):
    583     """Flush all current commands to the GLIR interpreter."""
--> 584     self._shared.flush(parser)

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/vispy/gloo/glir.py:506, in _GlirQueueShare.flush(self, parser)
    504     show = self._verbose if isinstance(self._verbose, str) else None
    505     self.show(show)
--> 506 parser.parse(self._filter(self.clear(), parser))

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/vispy/gloo/glir.py:824, in GlirParser.parse(self, commands)
    821     self._objects.pop(id_)
    823 for command in commands:
--> 824     self._parse(command)

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/vispy/gloo/glir.py:804, in GlirParser._parse(self, command)
    801 # elif cmd == 'SHADERS':  # Program
    802 #     ob.set_shaders(*args)
    803 elif cmd == 'LINK':  # Program
--> 804     ob.link_program(*args)
    805 elif cmd == 'WRAPPING':  # Texture1D, Texture2D, Texture3D
    806     ob.set_wrapping(*args)

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/vispy/gloo/glir.py:1118, in GlirProgram.link_program(self)
   1115 gl.glLinkProgram(self._handle)
   1116 if not gl.glGetProgramParameter(self._handle, gl.GL_LINK_STATUS):
   1117     raise RuntimeError('Program linking error:\n%s'
-> 1118                        % gl.glGetProgramInfoLog(self._handle))
   1120 # Detach all shaders to prepare them for deletion (they are no longer
   1121 # needed after linking is complete)
   1122 for shader in self._attached_shaders:

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/vispy/gloo/gl/_pyopengl2.py:112, in glGetProgramInfoLog(program)
    111 def glGetProgramInfoLog(program):
--> 112     res = GL.glGetProgramInfoLog(program)
    113     return res.decode('utf-8') if isinstance(res, bytes) else res

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/OpenGL/latebind.py:63, in Curry.__call__(self, *args, **named)
     61 def __call__( self, *args, **named ):
     62     """returns self.wrapperFunction( self.baseFunction, *args, **named )"""
---> 63     return self.wrapperFunction( self.baseFunction, *args, **named )

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/OpenGL/GL/VERSION/GL_2_0.py:356, in glGetProgramInfoLog(baseOperation, obj)
    350 @_lazy(glGetProgramInfoLog)
    351 def glGetProgramInfoLog(baseOperation, obj):
    352     """Retrieve the shader program's error messages as a Python string
    353 
    354     returns string which is '' if no message
    355     """
--> 356     length = int(glGetProgramiv(obj, GL_INFO_LOG_LENGTH))
    357     if length > 0:
    358         log = ctypes.create_string_buffer(length)

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/OpenGL/latebind.py:43, in LateBind.__call__(self, *args, **named)
     36 """Call self._finalCall, calling finalise() first if not already called
     37 
     38 There's actually *no* reason to unpack and repack the arguments,
     39 but unfortunately I don't know of a Cython syntax to specify
     40 that.
     41 """
     42 try:
---> 43     return self._finalCall( *args, **named )
     44 except (TypeError,AttributeError) as err:
     45     if self._finalCall is None:

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/OpenGL/wrapper.py:771, in Wrapper.finaliseCall.<locals>.wrapperCall(*args)
    769     err.cArgs = cArgs
    770     err.pyArgs = pyArgs
--> 771     raise err
    772 return returnValues(
    773     result,
    774     self,
    775     pyArgs,
    776     cArgs,
    777 )

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/OpenGL/wrapper.py:764, in Wrapper.finaliseCall.<locals>.wrapperCall(*args)
    762 cArguments = cArgs
    763 try:
--> 764     result = wrappedOperation(*cArguments)
    765 except ctypes.ArgumentError as err:
    766     err.args = err.args + (cArguments,)

File ~/work/workshops/workshops/.pixi/envs/dev/lib/python3.13/site-packages/OpenGL/error.py:230, in _ErrorChecker.glCheckError(self, result, baseOperation, cArguments, *args)
    228 err = self._currentChecker()
    229 if err != self._noErrorResult:
--> 230     raise self._errorClass(
    231         err,
    232         result,
    233         cArguments = cArguments,
    234         baseOperation = baseOperation,
    235     )
    236 return result

GLError: GLError(
	err = 1281,
	baseOperation = glGetProgramiv,
	pyArgs = (
		6,
		GL_INFO_LOG_LENGTH,
		<object object at 0x7f3097620660>,
	),
	cArgs = (
		6,
		GL_INFO_LOG_LENGTH,
		array([0], dtype=int32),
	),
	cArguments = (
		6,
		GL_INFO_LOG_LENGTH,
		array([0], dtype=int32),
	)
)

In Block 3, we’ll take our programmatic understanding of napari to the next step by creating an interactive widget with sliders, so we can tune parameters in real time — without writing any GUI code.

Sharing Time (5 min)

Share a screenshot on the #workshops stream on Zulip: press Alt+C to copy the canvas, then paste into Zulip.