diff --git a/.gitignore b/.gitignore index 2d0cba7..2303c72 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ __pycache__ +.coverage +htmlcov/ *.pyc *.pyo *.egg-info diff --git a/README.md b/README.md index f7d6a0e..fbf8918 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,16 @@ On every PR, an app with the same name as your branch is deployed to the Dash playground instance so that you can change whether your changes did not break the package. +Release procedure: + +* Bump version in `__init__.py` (and commit this change). +* Run `git tag v?.? && git push origin v?.?` +* On GH, turn that tag into a release and write release notes. +* Clear the dist dir. +* Run `python setup.py sdist bdist_wheel` +* Run `twine upload dist/*` +* Bump version of dash-slicer in dash-docs. + ## Reference @@ -122,23 +132,23 @@ color can be a list of such colors, defining a colormap. **property `VolumeSlicer.axis`** (`int`): The axis to slice. -**property `VolumeSlicer.clim`**: A `dcc.Store` representing the contrast limits as a 2-element tuple. -This value should probably not be changed too often (e.g. on slider drag) -because the thumbnail data is recreated on each change. +**property `VolumeSlicer.clim`**: A `dcc.Store` to be used as Output, representing the contrast +limits as a 2-element tuple. This value should probably not be +changed too often (e.g. on slider drag) because the thumbnail +data is recreated on each change. -**property `VolumeSlicer.extra_traces`**: A `dcc.Store` that can be used as an output to define -additional traces to be shown in this slicer. The data must be -a list of dictionaries, with each dict representing a raw trace -object. +**property `VolumeSlicer.extra_traces`**: A `dcc.Store` to be used as an Output to define additional +traces to be shown in this slicer. The data must be a list of +dictionaries, with each dict representing a raw trace object. **property `VolumeSlicer.graph`**: The `dcc.Graph` for this slicer. Use `graph.figure` to access the Plotly Figure object. **property `VolumeSlicer.nslices`** (`int`): The number of slices for this slicer. -**property `VolumeSlicer.overlay_data`**: A `dcc.Store` containing the overlay data. The form of this -data is considered an implementation detail; users are expected to use -`create_overlay_data` to create it. +**property `VolumeSlicer.overlay_data`**: A `dcc.Store` to be used an Output for the overlay data. The +form of this data is considered an implementation detail; users +are expected to use `create_overlay_data` to create it. **property `VolumeSlicer.scene_id`** (`str`): The id of the "virtual scene" for this slicer. Slicers that have the same scene_id show each-other's positions. @@ -148,7 +158,8 @@ don't want to use the slider, wrap it in a div with style `display: none`. **property `VolumeSlicer.state`**: A `dcc.Store` representing the current state of the slicer (present -in slicer.stores). Its data is a dict with the fields: +in slicer.stores). This store is intended for use as State or Input. +Its data is a dict with the fields: * "index": the integer slice index. * "index_changed": a bool indicating whether the index changed since last time. @@ -163,7 +174,8 @@ pattern matching Input. Its field are: context, scene, name. Where scene is the scene_id and name is "state". **property `VolumeSlicer.stores`**: A list of `dcc.Store` objects that the slicer needs to work. -These must be added to the app layout. +These must be added to the app layout. Note that public stores +like `state` and `extra_traces` are also present in this list. diff --git a/dash_slicer/docs.py b/dash_slicer/docs.py index b5d73bb..6aaa21f 100644 --- a/dash_slicer/docs.py +++ b/dash_slicer/docs.py @@ -6,6 +6,7 @@ import dash_slicer +# The seperator in the readme.md md_seperator = "" # noqa diff --git a/dash_slicer/slicer.py b/dash_slicer/slicer.py index 2effa68..f36c79a 100644 --- a/dash_slicer/slicer.py +++ b/dash_slicer/slicer.py @@ -85,12 +85,15 @@ from dash.dependencies import Input, Output, State, ALL from dash_core_components import Graph, Slider, Store, Interval -from .utils import img_array_to_uri, get_thumbnail_size, shape3d_to_size2d +from .utils import ( + discrete_colors, + img_array_to_uri, + get_thumbnail_size, + shape3d_to_size2d, + mask_to_coloured_slices, +) -# The default colors to use for indicators and overlays -discrete_colors = plotly.colors.qualitative.D3 - _assigned_scene_ids = {} # id(volume) -> str @@ -170,14 +173,14 @@ def __init__( elif isinstance(clim, (tuple, list)) and len(clim) == 2: self._initial_clim = float(clim[0]), float(clim[1]) else: - raise ValueError("The clim must be None or a 2-tuple of floats.") + raise TypeError("The clim must be None or a 2-tuple of floats.") # Check and store thumbnail if not (isinstance(thumbnail, (int, bool))): - raise ValueError("thumbnail must be a boolean or an integer.") - if thumbnail is False: + raise TypeError("thumbnail must be a boolean or an integer.") + if not thumbnail: self._thumbnail_param = None - elif thumbnail is None or thumbnail is True: + elif thumbnail is True: self._thumbnail_param = 32 # default size else: thumbnail = int(thumbnail) @@ -214,15 +217,23 @@ def __init__( "offset": shape3d_to_size2d(origin, axis), "stepsize": shape3d_to_size2d(spacing, axis), "color": color, + "infoid": np.random.randint(1, 9999999), } + # Also store thumbnail size. The get_thumbnail_size() is a bit like + # a simulation to get the low-res size. + if self._thumbnail_param is None: + self._slice_info["thumbnail_size"] = self._slice_info["size"][:2] + else: + self._slice_info["thumbnail_size"] = get_thumbnail_size( + self._slice_info["size"][:2], self._thumbnail_param + ) + # Build the slicer self._create_dash_components() self._create_server_callbacks() self._create_client_callbacks() - # Note(AK): we could make some stores public, but let's do this only when actual use-cases arise? - @property def scene_id(self) -> str: """The id of the "virtual scene" for this slicer. Slicers that have @@ -258,14 +269,16 @@ def slider(self): @property def stores(self): """A list of `dcc.Store` objects that the slicer needs to work. - These must be added to the app layout. + These must be added to the app layout. Note that public stores + like `state` and `extra_traces` are also present in this list. """ return self._stores @property def state(self): """A `dcc.Store` representing the current state of the slicer (present - in slicer.stores). Its data is a dict with the fields: + in slicer.stores). This store is intended for use as State or Input. + Its data is a dict with the fields: * "index": the integer slice index. * "index_changed": a bool indicating whether the index changed since last time. @@ -283,26 +296,26 @@ def state(self): @property def clim(self): - """A `dcc.Store` representing the contrast limits as a 2-element tuple. - This value should probably not be changed too often (e.g. on slider drag) - because the thumbnail data is recreated on each change. + """A `dcc.Store` to be used as Output, representing the contrast + limits as a 2-element tuple. This value should probably not be + changed too often (e.g. on slider drag) because the thumbnail + data is recreated on each change. """ return self._clim @property def extra_traces(self): - """A `dcc.Store` that can be used as an output to define - additional traces to be shown in this slicer. The data must be - a list of dictionaries, with each dict representing a raw trace - object. + """A `dcc.Store` to be used as an Output to define additional + traces to be shown in this slicer. The data must be a list of + dictionaries, with each dict representing a raw trace object. """ return self._extra_traces @property def overlay_data(self): - """A `dcc.Store` containing the overlay data. The form of this - data is considered an implementation detail; users are expected to use - `create_overlay_data` to create it. + """A `dcc.Store` to be used an Output for the overlay data. The + form of this data is considered an implementation detail; users + are expected to use `create_overlay_data` to create it. """ return self._overlay_data @@ -312,71 +325,13 @@ def create_overlay_data(self, mask, color=None): The color can be a hex color or an rgb/rgba tuple. Alternatively, color can be a list of such colors, defining a colormap. """ - # Check the mask if mask is None: return [None for index in range(self.nslices)] # A reset - elif not isinstance(mask, np.ndarray): - raise TypeError("Mask must be an ndarray or None.") - elif mask.dtype not in (np.bool, np.uint8): - raise ValueError(f"Mask must have bool or uint8 dtype, not {mask.dtype}.") elif mask.shape != self._volume.shape: raise ValueError( f"Overlay must has shape {mask.shape}, but expected {self._volume.shape}" ) - mask = mask.astype(np.uint8, copy=False) # need int to index - - # Create a colormap (list) from the given color(s) - if color is None: - colormap = discrete_colors[3:] - elif isinstance(color, str): - colormap = [color] - elif isinstance(color, (tuple, list)) and all( - isinstance(x, (int, float)) for x in color - ): - colormap = [color] - else: - colormap = list(color) - - # Normalize the colormap so each element is a 4-element tuple - for i in range(len(colormap)): - c = colormap[i] - if isinstance(c, str): - if c.startswith("#"): - c = plotly.colors.hex_to_rgb(c) - else: - raise ValueError( - "Named colors are not (yet) supported, hex colors are." - ) - c = tuple(int(x) for x in c) - if len(c) == 3: - c = c + (100,) - elif len(c) != 4: - raise ValueError("Expected color tuples to be 3 or 4 elements.") - colormap[i] = c - - # Insert zero stub color for where mask is zero - colormap.insert(0, (0, 0, 0, 0)) - - # Produce slices (base64 png strings) - overlay_slices = [] - for index in range(self.nslices): - # Sample the slice - indices = [slice(None), slice(None), slice(None)] - indices[self._axis] = index - im = mask[tuple(indices)] - max_mask = im.max() - if max_mask == 0: - # If the mask is all zeros, we can simply not draw it - overlay_slices.append(None) - else: - # Turn into rgba - while len(colormap) <= max_mask: - colormap.append(colormap[-1]) - colormap_arr = np.array(colormap) - rgba = colormap_arr[im] - overlay_slices.append(img_array_to_uri(rgba)) - - return overlay_slices + return mask_to_coloured_slices(mask, self._axis, color) def _subid(self, name, use_dict=False, **kwargs): """Given a name, get the full id including the context id prefix.""" @@ -412,15 +367,6 @@ def _create_dash_components(self): """Create the graph, slider, figure, etc.""" info = self._slice_info - # Prep low-res slices. The get_thumbnail_size() is a bit like - # a simulation to get the low-res size. - if self._thumbnail_param is None: - info["thumbnail_size"] = info["size"] - else: - info["thumbnail_size"] = get_thumbnail_size( - info["size"][:2], self._thumbnail_param - ) - # Create the figure object - can be accessed by user via slicer.graph.figure self._fig = fig = plotly.graph_objects.Figure(data=[]) fig.update_layout( @@ -469,10 +415,10 @@ def _create_dash_components(self): # A dict of static info for this slicer self._info = Store(id=self._subid("info"), data=info) - # A list of contrast limits + # A tuple representing the contrast limits self._clim = Store(id=self._subid("clim"), data=self._initial_clim) - # A list of low-res slices, or the full-res data (encoded as base64-png) + # A list of thumbnails (low-res, or the full-re, encoded as base64-png) self._thumbs_data = Store(id=self._subid("thumbs"), data=[]) # A list of mask slices (encoded as base64-png or null) @@ -483,13 +429,13 @@ def _create_dash_components(self): id=self._subid("server-data"), data={"index": -1, "slice": None} ) - # Store image traces for the slicer. + # Store image traces to show in the figure self._img_traces = Store(id=self._subid("img-traces"), data=[]) - # Store indicator traces for the slicer. + # Store indicator traces to show in the figure self._indicator_traces = Store(id=self._subid("indicator-traces"), data=[]) - # Store user traces for the slider. + # Store more (user-defined) traces to show in the figure self._extra_traces = Store(id=self._subid("extra-traces"), data=[]) # A timer to apply a rate-limit between slider.value and index.data @@ -554,12 +500,17 @@ def _create_client_callbacks(self): # \ server_data (a new slice) # \ \ # \ --> image_traces - # ----------------------- / \ - # -----> figure + # ------------------------/ \ + # \ + # state (external) --> indicator_traces -- -----> figure # / - # indicator_traces - # / - # state (external) + # extra_traces + # + # This figure is incomplete, for the sake of keeping it + # relatively simple. E.g. the thumbnail data is also an input + # for the callback that generates the image traces. And the + # clim store is an input for the callbacks that produce + # server_data and thumbnail data. app = self._app @@ -667,6 +618,7 @@ def _create_client_callbacks(self): Input(self._graph.id, "relayoutData"), Input(self._timer.id, "n_intervals"), ], + prevent_initial_call=True, ) # ---------------------------------------------------------------------- @@ -687,6 +639,10 @@ def _create_client_callbacks(self): if (!(private_state.timeout && now >= private_state.timeout)) { return dash_clientside.no_update; } + // Give the plot time to settle the initial axis ranges + if (n_intervals < 5) { + return dash_clientside.no_update; + } // Disable the timer private_state.timeout = 0; @@ -732,10 +688,11 @@ def _create_client_callbacks(self): axis: info.axis, color: info.color, }; - if (index != private_state.last_index) { + if (index != private_state.last_index || info.infoid != private_state.infoid) { private_state.last_index = index; new_state.index_changed = true; } + private_state.infoid = info.infoid; // infoid changes on hot reload return new_state; } """.replace( diff --git a/dash_slicer/utils.py b/dash_slicer/utils.py index 0bd026d..64e0516 100644 --- a/dash_slicer/utils.py +++ b/dash_slicer/utils.py @@ -1,10 +1,27 @@ +""" +Utilities for the slicer. Implementing these as separete functions keeps +the code in slicer.py smaller, and makes it easier to test. +""" + import io import base64 +import plotly import numpy as np import PIL.Image +# The default colors to use for indicators and overlays +discrete_colors = plotly.colors.qualitative.D3 + + +def _thumbnail_size_from_scalar(image_size, ref_size): + if image_size[0] > image_size[1]: + return int(ref_size * image_size[0] / image_size[1]), ref_size + else: + return ref_size, int(ref_size * image_size[1] / image_size[0]) + + def img_as_ubyte(img): """Quick-n-dirty conversion function. We'll have explicit contrast limits eventually. @@ -18,24 +35,13 @@ def img_as_ubyte(img): return img.astype(np.uint8) -def _thumbnail_size_from_scalar(image_size, ref_size): - if image_size[0] > image_size[1]: - return int(ref_size * image_size[0] / image_size[1]), ref_size - else: - return ref_size, int(ref_size * image_size[1] / image_size[0]) - - def img_array_to_uri(img_array, ref_size=None): """Convert the given image (numpy array) into a base64-encoded PNG.""" img_array = img_as_ubyte(img_array) - # todo: leverage this Plotly util once it becomes part of the public API (also drops the Pillow dependency) - # from plotly.express._imshow import _array_to_b64str - # return _array_to_b64str(img_array) img_pil = PIL.Image.fromarray(img_array) if ref_size: size = img_array.shape[1], img_array.shape[0] img_pil.thumbnail(_thumbnail_size_from_scalar(size, ref_size)) - # The below was taken from plotly.utils.ImageUriValidator.pil_image_to_uri() f = io.BytesIO() img_pil.save(f, format="PNG") base64_str = base64.b64encode(f.getvalue()).decode() @@ -63,3 +69,73 @@ def shape3d_to_size2d(shape, axis): size = list(reversed(shape)) size.append(axis_value) return tuple(size) + + +def mask_to_coloured_slices(mask, axis, color=None): + """Turn a mask into a list of base64 encoded coloured slices. + Set mask to `None` to clear the mask. The color can be a hex color + or an rgb/rgba tuple. Alternatively, color can be a list of such + colors, defining a colormap. + """ + + # Check the mask + if not isinstance(mask, np.ndarray): + raise TypeError("Mask must be an ndarray or None.") + elif mask.dtype not in (np.bool, np.uint8): + raise ValueError(f"Mask must have bool or uint8 dtype, not {mask.dtype}.") + + mask = mask.astype(np.uint8, copy=False) # need int to index + nslices = mask.shape[axis] + + # Create a colormap (list) from the given color(s) + if color is None: + colormap = discrete_colors[3:] + elif isinstance(color, str): + colormap = [color] + elif isinstance(color, (tuple, list)) and all( + isinstance(x, (int, float)) for x in color + ): + colormap = [color] + else: + colormap = list(color) + + # Normalize the colormap so each element is a 4-element tuple + for i in range(len(colormap)): + c = colormap[i] + if isinstance(c, str): + if c.startswith("#"): + c = plotly.colors.hex_to_rgb(c) + else: + raise ValueError( + "Named colors are not (yet) supported, hex colors are." + ) + c = tuple(int(x) for x in c) + if len(c) == 3: + c = c + (100,) + elif len(c) != 4: + raise ValueError("Expected color tuples to be 3 or 4 elements.") + colormap[i] = c + + # Insert zero stub color for where mask is zero + colormap.insert(0, (0, 0, 0, 0)) + + # Produce slices (base64 png strings) + overlay_slices = [] + for index in range(nslices): + # Sample the slice + indices = [slice(None), slice(None), slice(None)] + indices[axis] = index + im = mask[tuple(indices)] + max_mask = im.max() + if max_mask == 0: + # If the mask is all zeros, we can simply not draw it + overlay_slices.append(None) + else: + # Turn into rgba + while len(colormap) <= max_mask: + colormap.append(colormap[-1]) + colormap_arr = np.array(colormap) + rgba = colormap_arr[im] + overlay_slices.append(img_array_to_uri(rgba)) + + return overlay_slices diff --git a/setup.py b/setup.py index ac27254..518a36a 100644 --- a/setup.py +++ b/setup.py @@ -1,9 +1,5 @@ """ Setup script to distribute dash-slicer. - -* clear your dist dir -* python setup.py sdist bdist_wheel -* twine upload dist/* """ import re diff --git a/tests/test_slicer.py b/tests/test_slicer.py index 82b5faf..621328c 100644 --- a/tests/test_slicer.py +++ b/tests/test_slicer.py @@ -11,6 +11,10 @@ def test_slicer_init(): vol = np.random.uniform(0, 255, (100, 100, 100)).astype(np.uint8) + # Need a dash app + with raises(TypeError): + VolumeSlicer("not a dash app", vol) + # Need a valid volume with raises(TypeError): VolumeSlicer(app, [3, 4, 5]) @@ -22,13 +26,18 @@ def test_slicer_init(): VolumeSlicer(app, vol, axis=4) # Need a valide thumbnail - with raises(ValueError): + with raises(TypeError): VolumeSlicer(app, vol, thumbnail=20.2) + # Need a valide scene id + with raises(TypeError): + VolumeSlicer(app, vol, scene_id=20) + # This works s = VolumeSlicer(app, vol) # Check properties + assert s.axis == 0 assert isinstance(s.graph, dcc.Graph) assert isinstance(s.slider, dcc.Slider) assert isinstance(s.stores, list) @@ -50,6 +59,33 @@ def test_slicer_thumbnail(): # No server-side callbacks when no thumbnails are used assert not any(["server-data.data" in key for key in app.callback_map]) + # Default thumbnail size + s = VolumeSlicer(app, vol) + assert s._slice_info["thumbnail_size"] == (32, 32) + + # Custom size + s = VolumeSlicer(app, vol, thumbnail=20) + assert s._slice_info["thumbnail_size"] == (20, 20) + s = VolumeSlicer(app, vol, thumbnail=50) + assert s._slice_info["thumbnail_size"] == (50, 50) + + # Custom but too big + s = VolumeSlicer(app, vol, thumbnail=102) + assert s._slice_info["thumbnail_size"] == (100, 100) + + # Disable + s = VolumeSlicer(app, vol, thumbnail=False) + assert s._slice_info["thumbnail_size"] == (100, 100) + s = VolumeSlicer(app, vol, thumbnail=0) + assert s._slice_info["thumbnail_size"] == (100, 100) + s = VolumeSlicer(app, vol, thumbnail=-1) + assert s._slice_info["thumbnail_size"] == (100, 100) + + # None is simply not allowed - removes ambiguity about + # whether None means no-thumbnails, or the default size + with raises(TypeError): + VolumeSlicer(app, vol, thumbnail=None) + def test_clim(): app = dash.Dash() @@ -65,6 +101,12 @@ def test_clim(): s = VolumeSlicer(app, vol, clim=(10, 12)) assert s._initial_clim == (10, 12) + # Fails + with raises(TypeError): + VolumeSlicer(app, vol, clim=10) + with raises(TypeError): + VolumeSlicer(app, vol, clim=(10, 12, 14)) + def test_scene_id_and_context_id(): app = dash.Dash() @@ -82,6 +124,26 @@ def test_scene_id_and_context_id(): assert s1._context_id != s2._context_id and s1._context_id != s3._context_id +def test_slice(): + app = dash.Dash() + vol = np.random.uniform(0, 255, (10, 20, 30)).astype(np.uint8) + + s = VolumeSlicer(app, vol, axis=0) + im = s._slice(1, (0, 100)) + assert im.dtype == np.uint8 + assert im.shape == (20, 30) + + s = VolumeSlicer(app, vol, axis=1) + im = s._slice(1, (0, 100)) + assert im.dtype == np.uint8 + assert im.shape == (10, 30) + + s = VolumeSlicer(app, vol, axis=2) + im = s._slice(1, (0, 100)) + assert im.dtype == np.uint8 + assert im.shape == (10, 20) + + def test_create_overlay_data(): app = dash.Dash() @@ -93,16 +155,6 @@ def test_create_overlay_data(): assert isinstance(overlay, list) and len(overlay) == s.nslices assert all(isinstance(x, str) for x in overlay) - # Bool overlay - with color - overlay = s.create_overlay_data(vol > 10, "#ff0000") - assert isinstance(overlay, list) and len(overlay) == s.nslices - assert all(isinstance(x, str) for x in overlay) - - # Uint8 overlay - with colormap - overlay = s.create_overlay_data(vol.astype(np.uint8), ["#ff0000", "#00ff00"]) - assert isinstance(overlay, list) and len(overlay) == s.nslices - assert all(isinstance(x, str) for x in overlay) - # Reset overlay = s.create_overlay_data(None) assert isinstance(overlay, list) and len(overlay) == s.nslices @@ -114,9 +166,5 @@ def test_create_overlay_data(): assert all(x is None for x in overlay) # Wrong - with raises(TypeError): - s.create_overlay_data("not a valid mask") - with raises(ValueError): - s.create_overlay_data(vol.astype(np.float32)) # wrong dtype with raises(ValueError): s.create_overlay_data(vol[:-1]) # wrong shape diff --git a/tests/test_utils.py b/tests/test_utils.py index c4f30a2..08fe1e8 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -3,6 +3,7 @@ img_array_to_uri, get_thumbnail_size, shape3d_to_size2d, + mask_to_coloured_slices, ) import numpy as np @@ -58,3 +59,55 @@ def test_shape3d_to_size2d(): with raises(IndexError): shape3d_to_size2d((12, 13, 14), 3) + + +def test_mask_to_coloured_slices(): + vol = np.random.uniform(0, 255, (10, 20, 30)).astype(np.uint8) + mask = vol > 20 + + # Check handling of axis + assert len(mask_to_coloured_slices(mask, 0)) == 10 + assert len(mask_to_coloured_slices(mask, 1)) == 20 + assert len(mask_to_coloured_slices(mask, 2)) == 30 + + # Bool overlay + overlay = mask_to_coloured_slices(mask, 0) + assert isinstance(overlay, list) + assert all(isinstance(x, str) for x in overlay) + + # Bool overlay - with color + overlay = mask_to_coloured_slices(mask, 0, "#ff0000") + assert isinstance(overlay, list) + assert all(isinstance(x, str) for x in overlay) + + # Bool overlay - with color rgb + overlay = mask_to_coloured_slices(mask, 0, [0, 255, 0]) + assert all(isinstance(x, str) for x in overlay) + + # Bool overlay - with color rgba + overlay = mask_to_coloured_slices(mask, 0, [0, 255, 0, 100]) + assert all(isinstance(x, str) for x in overlay) + + # Uint8 overlay - with colormap + overlay = mask_to_coloured_slices(vol.astype(np.uint8), 0, ["#ff0000", "#00ff00"]) + assert all(isinstance(x, str) for x in overlay) + + # Reset by zero mask + overlay = mask_to_coloured_slices(vol > 300, 0) + assert all(x is None for x in overlay) + + # Wrong + with raises(ValueError): + mask_to_coloured_slices(mask, 0, "red") # named colors not supported yet + with raises(ValueError): + mask_to_coloured_slices(mask, 0, [0, 255, 0, 100, 100]) # not a color + with raises(ValueError): + mask_to_coloured_slices(mask, 0, [0, 255]) # not a color + with raises(TypeError): + mask_to_coloured_slices("not a valid mask", 0) + with raises(TypeError): + mask_to_coloured_slices( + None, 0 + ) # note that the mask in create_overlay_data can be None + with raises(ValueError): + mask_to_coloured_slices(vol.astype(np.float32), 0) # wrong dtype