Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
__pycache__
.coverage
htmlcov/
*.pyc
*.pyo
*.egg-info
Expand Down
36 changes: 24 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.



Expand Down
1 change: 1 addition & 0 deletions dash_slicer/docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import dash_slicer


# The seperator in the readme.md
md_seperator = "<!--- The below is autogenerated - do not edit --->" # noqa


Expand Down
159 changes: 58 additions & 101 deletions dash_slicer/slicer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand All @@ -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."""
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -667,6 +618,7 @@ def _create_client_callbacks(self):
Input(self._graph.id, "relayoutData"),
Input(self._timer.id, "n_intervals"),
],
prevent_initial_call=True,
)

# ----------------------------------------------------------------------
Expand All @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
Loading