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:
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'# Reset for next section
nuclei_layer.colormap = 'cyan'
nuclei_layer.contrast_limits = (0, 65535)
nuclei_layer.opacity = 1.04. 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 canvasNow 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 dataAxis 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 = TrueThe 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:
File metadata — read-only properties (shape, dtype, file path)
Axes metadata — editable axis labels, scale, translation, and units
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>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')Explore more OME-Zarr datasets¶
2024 NGFF Challenge — filter by organism, modality, or dimension count
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)
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]>
# the 0th data is the highest resolution, while the -1th data is the coursest
nimg.layer_data[-1]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)¶
What was the most interesting image you explored? Why?
Did managing and visualizing metadata improve your understanding of the data?
Share a screenshot on the #workshops stream on
Zulip: press Alt+C to copy the canvas,
then paste into Zulip.