Napari lazy loading from OMERO

In response to @talley’s nice tutorial on dask.delayed at https://napari.org/tutorials/dask,
I adapted it to lazy load pixel data from OMERO (with caching of the planes):

lazy_load_omero.py
from dask import delayed
import dask.array as da
import napari
from vispy.color import Colormap
from omero.gateway import BlitzGateway

conn = BlitzGateway("username", "password", port=4064, host="localhost")
conn.connect()
conn.SERVICE_OPTS.setOmeroGroup('-1')

IMAGE_ID = 4424
image = conn.getObject("Image", IMAGE_ID)
print(image.name)
cache = {}

def get_lazy_stack(img, c=0):
    sz = img.getSizeZ()
    st = img.getSizeT()
    plane_names = ["%s,%s,%s" % (z, c, t) for t in range(st) for z in range(sz)]

    def get_plane(plane_name):
        if plane_name in cache:
            return cache[plane_name]
        z, c, t = [int(n) for n in plane_name.split(",")]
        print('get_plane', z, c, t)
        pixels = img.getPrimaryPixels()
        p = pixels.getPlane(z, c, t)
        cache[plane_name] = p
        return p

    # read the first file to get the shape and dtype
    # ASSUMES THAT ALL FILES SHARE THE SAME SHAPE/TYPE
    sample = get_plane(plane_names[0])

    lazy_imread = delayed(get_plane)  # lazy reader
    lazy_arrays = [lazy_imread(pn) for pn in plane_names]
    dask_arrays = [
        da.from_delayed(delayed_reader, shape=sample.shape, dtype=sample.dtype)
        for delayed_reader in lazy_arrays
    ]
    # Stack into one large dask.array
    if sz == 1 or st == 1:
        return da.stack(dask_arrays, axis=0)

    z_stacks = []
    for t in range(st):
        z_stacks.append(da.stack(dask_arrays[t * sz: (t + 1) * sz], axis=0))
    stack = da.stack(z_stacks, axis=0)
    return stack


with napari.gui_qt():
    # specify contrast_limits and is_pyramid=False with big data
    # to avoid unecessary computations
    viewer = napari.Viewer()

    for c, channel in enumerate(image.getChannels()):
        print('loading channel %s' % c)
        data = get_lazy_stack(image, c=c)
        # use current rendering settings from OMERO
        start = channel.getWindowStart()
        end = channel.getWindowEnd()
        color = channel.getColor().getRGB()
        color = [r/256 for r in color]
        cmap = Colormap([[0, 0, 0], color])
        # Z-scale for 3D viewing
        z_scale = 1
        if image.getSizeZ() > 1:
            size_x = image.getPixelSizeX()
            size_z = image.getPixelSizeZ()
            if size_x is not None and size_z is not None:
                z_scale = [1, size_z / size_x, 1, 1]
        viewer.add_image(data, blending='additive',
                        contrast_limits=[start, end],
                        is_pyramid=False,
                        colormap=('from_omero', cmap),
                        # scale=[1, z_scale, 1, 1],
                        name=channel.getLabel())

print('closing conn...')
conn.close()

This is working quite nicely; just a couple of comments:

If I un-comment setting the scale, # scale=[1, z_scale, 1, 1], I get the following error:

stack trace
Traceback (most recent call last):
  File "lazy_omero_3d.py", line 78, in <module>
    name=channel.getLabel())
  File "/Users/willadmin/Desktop/py3/venv/lib/python3.7/site-packages/napari/components/viewer_model.py", line 508, in add_image
    visible=visible,
  File "/Users/willadmin/Desktop/py3/venv/lib/python3.7/site-packages/napari/layers/image/image.py", line 208, in __init__
    self._update_dims()
  File "/Users/willadmin/Desktop/py3/venv/lib/python3.7/site-packages/napari/layers/base/base.py", line 372, in _update_dims
    self._set_view_slice()
  File "/Users/willadmin/Desktop/py3/venv/lib/python3.7/site-packages/napari/layers/image/image.py", line 482, in _set_view_slice
    image = np.asarray(self.data[self.dims.indices]).transpose(order)
  File "/Users/willadmin/Desktop/py3/venv/lib/python3.7/site-packages/napari/components/dims.py", line 266, in indices
    np.round(self.range[axis][1]) - 1,
  File "<__array_function__ internals>", line 6, in clip
  File "/Users/willadmin/Desktop/py3/venv/lib/python3.7/site-packages/numpy/core/fromnumeric.py", line 2037, in clip
    return _wrapfunc(a, 'clip', a_min, a_max, out=out, **kwargs)
  File "/Users/willadmin/Desktop/py3/venv/lib/python3.7/site-packages/numpy/core/fromnumeric.py", line 58, in _wrapfunc
    return _wrapit(obj, method, *args, **kwds)
  File "/Users/willadmin/Desktop/py3/venv/lib/python3.7/site-packages/numpy/core/fromnumeric.py", line 47, in _wrapit
    result = getattr(asarray(obj), method)(*args, **kwds)
  File "/Users/willadmin/Desktop/py3/venv/lib/python3.7/site-packages/numpy/core/_methods.py", line 132, in _clip
    um.clip, a, min, max, out=out, casting=casting, **kwargs)
  File "/Users/willadmin/Desktop/py3/venv/lib/python3.7/site-packages/numpy/core/_methods.py", line 85, in _clip_dep_invoke_with_casting
    return ufunc(*args, out=out, **kwargs)
ValueError: operands could not be broadcast together with shapes () (0,) (340,) 

I noticed that turning off 1 or more channels doesn’t prevent them from being loaded. For images with a large numbers of channels, where you only want to view 1 or 2 of them, it would be nice if they are not requested via dask unless they are active in the viewer.

I’m using the current rendering settings in OMERO to set the contrast limits in napari. This makes the image appear the same in napari as it does in OMERO. But, what I really want to do is set the contrast limits to the full intensity range for that image and set the current settings to be within those limits. Is there any way to do that in napari?

Many Thanks,

Will

4 Likes

This is awesome @will-moore!

Regarding the contrast limits, there isn’t a nice public method for adjusting the available range of the slider, but you can accomplish what I think you’re after like this (private methods here, so API may change in the future):

# grab the layer you're interested in, e.g.
layer = viewer.layers[0] 
# set the available range of the slider
layer._contrast_limits_range = [min, max]
# in order to update you can either just reassign the 
# current layer contrast limits
layer.contrast_limits = layer.contrast_limits
# or you can manually call update on the slider widget
# but getting to it is a bit complicated:
ctrl = viewer.window.qt_viewer.controls.widgets[layer]
ctrl.contrast_limits_slider_update()
2 Likes

Very cool @will-moore - sorry that scale is still giving you problems. We’ll add this to the list of things to make sure we fix when we upgrade our approach to coordinate systems - see https://github.com/napari/napari/issues/763.

We can also think of better ways to expose layer._contrast_limits_range too. We can certainly just make those public layer.contrast_limits_range, but I think we may avoid making them a keyword arg just to prevent the main api from getting too cluttered.

I noticed that turning off 1 or more channels doesn’t prevent them from being loaded. For images with a large numbers of channels, where you only want to view 1 or 2 of them, it would be nice if they are not requested via dask unless they are active in the viewer.

You might also be interested in trying the very latest napari on master where we just made it such that doing viewer.add_image(data, visible=False) will cause the data to get added to the viewer in a lazy fashion - i.e. nothing will be requested from dask until you click the visible icon in the GUI. Note that you won’t get a thumbnail until that point either. https://github.com/napari/napari/pull/776. Curious if that functionality is what you had in mind / works for you. We should be making a new 0.2.7 release fairly soon with that in it.

2 Likes

Just to follow-up: napari 0.2.7 worked great for only loading visible channels, thanks.

I tried the contrast_limits_range too, but I didn’t see effect and the values are unchanged:
E.g. in the viewer terminal:

layer = viewer.layers[0]

layer._contrast_limits_range
Out[2]: [66.0, 2248.0]

layer._contrast_limits_range = [500, 1000]
layer.contrast_limits = layer.contrast_limits
ctrl = viewer.window.qt_viewer.controls.widgets[layer]
ctrl.contrast_limits_slider_update()

layer._contrast_limits_range
Out[7]: [66.0, 2248.0]

Actually, layer._contrast_limits_range gave me the same (original) output even after adjusting the slider in the UI, e.g., to 502, 998:

Screen Shot 2020-01-06 at 14.35.55

Thanks,
Will.

1 Like

layer._contrast_limits_range will always be at least as large as layer.contrast_limits, so my example above assumed that you wanted a range that was at least as large as the current contrast_limits. I can’t tell from your example here, but if layer.contrast_limits also started out the same as the range did ([66.0, 2248.0]), then when you used that layer.contrast_limits = layer.contrast_limits trick to update the slider, then you essentially undid the layer._contrast_limits_range = [500, 1000]. But something like this should work:

layer._contrast_limits_range
Out[2]: [66.0, 2248.0]

layer._contrast_limits_range = [500, 1000]
layer.contrast_limits = [600, 900]

layer._contrast_limits_range
Out[7]: [500, 1000]

also, just to let you know, we recently added some features (PR#837) that should make this all much easier. In the next release, you will just be able to set layer.contrast_limits_range as desired (note the lack of the underscore), and be done with it (though, as before, contrast_limits_range will always be coerced to be larger that contrast_limits). Additionally, if you right click on the slider, an expanded version will show with finer tuned controls, and buttons to autoscale and/or set the slider range to the full bit depth (for integer types):

1 Like

Ah, sorry, I think I was getting _contrast_limits_range and contrast_limits mixed up somehow.
Thanks for the help and the pop-up slider looks great - look forward to it!

2 Likes