Skip to content

Commit ab62aa4

Browse files
authored
Add support for clim (contrast limits) (#47)
* Add support for contrast limits * Add contrast example * Update 3 other examples * update readme * add note to readme in generated code * tweak doc-build so test is more robust * fix handling of thumbnails, and a few tweaks * add tests for clim
1 parent 75e2f8b commit ab62aa4

10 files changed

+140
-44
lines changed

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,11 @@ the package.
7575

7676
## Reference
7777

78+
<!--- The below is autogenerated - do not edit --->
79+
7880
### The VolumeSlicer class
7981

80-
**class `VolumeSlicer(app, volume, *, spacing=None, origin=None, axis=0, reverse_y=True, scene_id=None, color=None, thumbnail=True)`**
82+
**class `VolumeSlicer(app, volume, *, spacing=None, origin=None, axis=0, reverse_y=True, clim=None, scene_id=None, color=None, thumbnail=True)`**
8183

8284
A slicer object to show 3D image data in Dash. Upon
8385
instantiation one can provide the following parameters:
@@ -95,6 +97,8 @@ instantiation one can provide the following parameters:
9597
the slice is in the top-left, rather than bottom-left. Default True.
9698
Note: setting this to False affects performance, see #12. This has been
9799
fixed, but the fix has not yet been released with Dash.
100+
* `clim` (tuple of `float`): the (initial) contrast limits. Default the min
101+
and max of the volume.
98102
* `scene_id` (`str`): the scene that this slicer is part of. Slicers
99103
that have the same scene-id show each-other's positions with
100104
line indicators. By default this is derived from `id(volume)`.
@@ -118,6 +122,10 @@ color can be a list of such colors, defining a colormap.
118122

119123
**property `VolumeSlicer.axis`** (`int`): The axis to slice.
120124

125+
**property `VolumeSlicer.clim`**: A `dcc.Store` representing the contrast limits as a 2-element tuple.
126+
This value should probably not be changed too often (e.g. on slider drag)
127+
because the thumbnail data is recreated on each change.
128+
121129
**property `VolumeSlicer.extra_traces`**: A `dcc.Store` that can be used as an output to define
122130
additional traces to be shown in this slicer. The data must be
123131
a list of dictionaries, with each dict representing a raw trace

dash_slicer/docs.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
import dash_slicer
77

88

9+
md_seperator = "<!--- The below is autogenerated - do not edit --->" # noqa
10+
11+
912
def dedent(text):
1013
"""Dedent a docstring, removing leading whitespace."""
1114
lines = text.lstrip().splitlines()

dash_slicer/slicer.py

Lines changed: 65 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ class VolumeSlicer:
111111
the slice is in the top-left, rather than bottom-left. Default True.
112112
Note: setting this to False affects performance, see #12. This has been
113113
fixed, but the fix has not yet been released with Dash.
114+
* `clim` (tuple of `float`): the (initial) contrast limits. Default the min
115+
and max of the volume.
114116
* `scene_id` (`str`): the scene that this slicer is part of. Slicers
115117
that have the same scene-id show each-other's positions with
116118
line indicators. By default this is derived from `id(volume)`.
@@ -137,6 +139,7 @@ def __init__(
137139
origin=None,
138140
axis=0,
139141
reverse_y=True,
142+
clim=None,
140143
scene_id=None,
141144
color=None,
142145
thumbnail=True,
@@ -161,21 +164,29 @@ def __init__(
161164
self._axis = int(axis)
162165
self._reverse_y = bool(reverse_y)
163166

167+
# Check and store contrast limits
168+
if clim is None:
169+
self._initial_clim = self._volume.min(), self._volume.max()
170+
elif isinstance(clim, (tuple, list)) and len(clim) == 2:
171+
self._initial_clim = float(clim[0]), float(clim[1])
172+
else:
173+
raise ValueError("The clim must be None or a 2-tuple of floats.")
174+
164175
# Check and store thumbnail
165176
if not (isinstance(thumbnail, (int, bool))):
166177
raise ValueError("thumbnail must be a boolean or an integer.")
167178
if thumbnail is False:
168-
self._thumbnail = False
179+
self._thumbnail_param = None
169180
elif thumbnail is None or thumbnail is True:
170-
self._thumbnail = 32 # default size
181+
self._thumbnail_param = 32 # default size
171182
else:
172183
thumbnail = int(thumbnail)
173184
if thumbnail >= np.max(volume.shape[:3]):
174-
self._thumbnail = False # dont go larger than image size
185+
self._thumbnail_param = None # dont go larger than image size
175186
elif thumbnail <= 0:
176-
self._thumbnail = False # consider 0 and -1 the same as False
187+
self._thumbnail_param = None # consider 0 and -1 the same as False
177188
else:
178-
self._thumbnail = thumbnail
189+
self._thumbnail_param = thumbnail
179190

180191
# Check and store scene id, and generate
181192
if scene_id is None:
@@ -207,8 +218,7 @@ def __init__(
207218

208219
# Build the slicer
209220
self._create_dash_components()
210-
if thumbnail:
211-
self._create_server_callbacks()
221+
self._create_server_callbacks()
212222
self._create_client_callbacks()
213223

214224
# Note(AK): we could make some stores public, but let's do this only when actual use-cases arise?
@@ -271,6 +281,14 @@ def state(self):
271281
"""
272282
return self._state
273283

284+
@property
285+
def clim(self):
286+
"""A `dcc.Store` representing the contrast limits as a 2-element tuple.
287+
This value should probably not be changed too often (e.g. on slider drag)
288+
because the thumbnail data is recreated on each change.
289+
"""
290+
return self._clim
291+
274292
@property
275293
def extra_traces(self):
276294
"""A `dcc.Store` that can be used as an output to define
@@ -377,31 +395,31 @@ def _subid(self, name, use_dict=False, **kwargs):
377395
assert not kwargs
378396
return self._context_id + "-" + name
379397

380-
def _slice(self, index):
398+
def _slice(self, index, clim):
381399
"""Sample a slice from the volume."""
400+
# Sample from the volume
382401
indices = [slice(None), slice(None), slice(None)]
383402
indices[self._axis] = index
384-
im = self._volume[tuple(indices)]
385-
return (im.astype(np.float32) * (255 / im.max())).astype(np.uint8)
403+
im = self._volume[tuple(indices)].astype(np.float32)
404+
# Apply contrast limits
405+
clim = min(clim), max(clim)
406+
im = (im - clim[0]) * (255 / (clim[1] - clim[0]))
407+
im[im < 0] = 0
408+
im[im > 255] = 255
409+
return im.astype(np.uint8)
386410

387411
def _create_dash_components(self):
388412
"""Create the graph, slider, figure, etc."""
389413
info = self._slice_info
390414

391415
# Prep low-res slices. The get_thumbnail_size() is a bit like
392416
# a simulation to get the low-res size.
393-
if not self._thumbnail:
394-
thumbnail_size = None
417+
if self._thumbnail_param is None:
395418
info["thumbnail_size"] = info["size"]
396419
else:
397-
thumbnail_size = self._thumbnail
398420
info["thumbnail_size"] = get_thumbnail_size(
399-
info["size"][:2], thumbnail_size
421+
info["size"][:2], self._thumbnail_param
400422
)
401-
thumbnails = [
402-
img_array_to_uri(self._slice(i), thumbnail_size)
403-
for i in range(info["size"][2])
404-
]
405423

406424
# Create the figure object - can be accessed by user via slicer.graph.figure
407425
self._fig = fig = plotly.graph_objects.Figure(data=[])
@@ -451,8 +469,11 @@ def _create_dash_components(self):
451469
# A dict of static info for this slicer
452470
self._info = Store(id=self._subid("info"), data=info)
453471

472+
# A list of contrast limits
473+
self._clim = Store(id=self._subid("clim"), data=self._initial_clim)
474+
454475
# A list of low-res slices, or the full-res data (encoded as base64-png)
455-
self._thumbs_data = Store(id=self._subid("thumbs"), data=thumbnails)
476+
self._thumbs_data = Store(id=self._subid("thumbs"), data=[])
456477

457478
# A list of mask slices (encoded as base64-png or null)
458479
self._overlay_data = Store(id=self._subid("overlay"), data=[])
@@ -482,6 +503,7 @@ def _create_dash_components(self):
482503

483504
self._stores = [
484505
self._info,
506+
self._clim,
485507
self._thumbs_data,
486508
self._overlay_data,
487509
self._server_data,
@@ -498,15 +520,29 @@ def _create_server_callbacks(self):
498520
app = self._app
499521

500522
@app.callback(
501-
Output(self._server_data.id, "data"),
502-
[Input(self._state.id, "data")],
523+
Output(self._thumbs_data.id, "data"),
524+
[Input(self._clim.id, "data")],
503525
)
504-
def upload_requested_slice(state):
505-
if state is None or not state["index_changed"]:
506-
return dash.no_update
507-
index = state["index"]
508-
slice = img_array_to_uri(self._slice(index))
509-
return {"index": index, "slice": slice}
526+
def upload_thumbnails(clim):
527+
return [
528+
img_array_to_uri(self._slice(i, clim), self._thumbnail_param)
529+
for i in range(self.nslices)
530+
]
531+
532+
if self._thumbnail_param is not None:
533+
# The callback to push full-res slices to the client is only needed
534+
# if the thumbnails are not already full-res.
535+
536+
@app.callback(
537+
Output(self._server_data.id, "data"),
538+
[Input(self._state.id, "data"), Input(self._clim.id, "data")],
539+
)
540+
def upload_requested_slice(state, clim):
541+
if state is None or not state["index_changed"]:
542+
return dash.no_update
543+
index = state["index"]
544+
slice = img_array_to_uri(self._slice(index, clim))
545+
return {"index": index, "slice": slice}
510546

511547
def _create_client_callbacks(self):
512548
"""Create the callbacks that run client-side."""
@@ -714,7 +750,7 @@ def _create_client_callbacks(self):
714750
State(self._info.id, "data"),
715751
State(self._graph.id, "figure"),
716752
],
717-
# prevent_initial_call=True,
753+
prevent_initial_call=True,
718754
)
719755

720756
# ----------------------------------------------------------------------
@@ -770,9 +806,9 @@ def _create_client_callbacks(self):
770806
Input(self._slider.id, "value"),
771807
Input(self._server_data.id, "data"),
772808
Input(self._overlay_data.id, "data"),
809+
Input(self._thumbs_data.id, "data"),
773810
],
774811
[
775-
State(self._thumbs_data.id, "data"),
776812
State(self._info.id, "data"),
777813
State(self._img_traces.id, "data"),
778814
],

examples/contrast.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""
2+
A small example demonstrating contrast limits.
3+
"""
4+
5+
import dash
6+
import dash_html_components as html
7+
import dash_core_components as dcc
8+
from dash.dependencies import Input, Output
9+
from dash_slicer import VolumeSlicer
10+
import imageio
11+
12+
13+
app = dash.Dash(__name__, update_title=None)
14+
15+
vol = imageio.volread("imageio:stent.npz")
16+
slicer = VolumeSlicer(app, vol, clim=(0, 1000))
17+
clim_slider = dcc.RangeSlider(
18+
id="clim-slider", min=vol.min(), max=vol.max(), value=(0, 1000)
19+
)
20+
21+
app.layout = html.Div([slicer.graph, slicer.slider, clim_slider, *slicer.stores])
22+
23+
24+
@app.callback(Output(slicer.clim.id, "data"), [Input("clim-slider", "value")])
25+
def update_clim(value):
26+
return value
27+
28+
29+
if __name__ == "__main__":
30+
# Note: dev_tools_props_check negatively affects the performance of VolumeSlicer
31+
app.run_server(debug=True, dev_tools_props_check=False)

examples/slicer_with_3_views.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717

1818
# Read volumes and create slicer objects
1919
vol = imageio.volread("imageio:stent.npz")
20-
slicer0 = VolumeSlicer(app, vol, axis=0)
21-
slicer1 = VolumeSlicer(app, vol, axis=1)
22-
slicer2 = VolumeSlicer(app, vol, axis=2)
20+
slicer0 = VolumeSlicer(app, vol, clim=(0, 800), axis=0)
21+
slicer1 = VolumeSlicer(app, vol, clim=(0, 800), axis=1)
22+
slicer2 = VolumeSlicer(app, vol, clim=(0, 800), axis=2)
2323

2424
# Calculate isosurface and create a figure with a mesh object
2525
verts, faces, _, _ = marching_cubes(vol, 300, step_size=4)
@@ -34,7 +34,7 @@
3434
app.layout = html.Div(
3535
style={
3636
"display": "grid",
37-
"gridTemplateColumns": "40% 40%",
37+
"gridTemplateColumns": "50% 50%",
3838
},
3939
children=[
4040
html.Div(
@@ -92,7 +92,9 @@
9292
let s = {
9393
type: 'scatter3d',
9494
x: xyz[0], y: xyz[1], z: xyz[2],
95-
mode: 'lines', line: {color: state.color}
95+
mode: 'lines', line: {color: state.color},
96+
hoverinfo: 'skip',
97+
showlegend: false,
9698
};
9799
traces.push(s);
98100
}

examples/threshold_contour.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
vol = imageio.volread("imageio:stent.npz")
2222
mi, ma = vol.min(), vol.max()
23-
slicer = VolumeSlicer(app, vol)
23+
slicer = VolumeSlicer(app, vol, clim=(0, 800))
2424

2525

2626
app.layout = html.Div(

examples/threshold_overlay.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
vol = imageio.volread("imageio:stent.npz")
2222
mi, ma = vol.min(), vol.max()
23-
slicer = VolumeSlicer(app, vol)
23+
slicer = VolumeSlicer(app, vol, clim=(0, 800))
2424

2525

2626
app.layout = html.Div(

tests/test_docs.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import os
22

3-
from dash_slicer.docs import get_reference_docs
3+
from dash_slicer.docs import get_reference_docs, md_seperator
44

55

66
HERE = os.path.dirname(os.path.abspath(__file__))
@@ -18,7 +18,7 @@ def test_that_reference_docs_in_readme_are_up_to_date():
1818
assert os.path.isfile(filename)
1919
with open(filename, "rb") as f:
2020
text = f.read().decode()
21-
_, _, ref = text.partition("## Reference")
21+
_, _, ref = text.partition(md_seperator)
2222
ref1 = ref.strip().replace("\r\n", "\n")
2323
ref2 = get_reference_docs().strip()
2424
assert (

tests/test_slicer.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,14 @@ def test_slicer_init():
3333
assert isinstance(s.slider, dcc.Slider)
3434
assert isinstance(s.stores, list)
3535
assert all(isinstance(store, (dcc.Store, dcc.Interval)) for store in s.stores)
36+
for store in [s.clim, s.state, s.extra_traces, s.overlay_data]:
37+
assert isinstance(store, dcc.Store)
3638

3739

3840
def test_slicer_thumbnail():
39-
app = dash.Dash()
4041
vol = np.random.uniform(0, 255, (100, 100, 100)).astype(np.uint8)
4142

43+
app = dash.Dash()
4244
_ = VolumeSlicer(app, vol)
4345
# Test for name pattern of server-side callback when thumbnails are used
4446
assert any(["server-data.data" in key for key in app.callback_map])
@@ -49,6 +51,21 @@ def test_slicer_thumbnail():
4951
assert not any(["server-data.data" in key for key in app.callback_map])
5052

5153

54+
def test_clim():
55+
app = dash.Dash()
56+
vol = np.random.uniform(0, 255, (10, 10, 10)).astype(np.uint8)
57+
mi, ma = vol.min(), vol.max()
58+
59+
s = VolumeSlicer(app, vol)
60+
assert s._initial_clim == (mi, ma)
61+
62+
s = VolumeSlicer(app, vol, clim=None)
63+
assert s._initial_clim == (mi, ma)
64+
65+
s = VolumeSlicer(app, vol, clim=(10, 12))
66+
assert s._initial_clim == (10, 12)
67+
68+
5269
def test_scene_id_and_context_id():
5370
app = dash.Dash()
5471

0 commit comments

Comments
 (0)