-
-
Notifications
You must be signed in to change notification settings - Fork 404
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(bokeh): Server side HoverTool for rasterized/datashaded plots with selector #6422
Open
hoxbro
wants to merge
62
commits into
main
Choose a base branch
from
inspect
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+405
−108
Open
Changes from 57 commits
Commits
Show all changes
62 commits
Select commit
Hold shift + click to select a range
db72f06
add custom hover tool for rasterized plot
hoxbro b0496de
Update datashader to not send all data to frontend
hoxbro 567e7cb
cleanup
hoxbro debc0ea
add xy to hovertool
hoxbro 238e620
Merge branch 'main' into inspect
hoxbro 560e562
clean up
hoxbro dbcfecf
add support for Jupyter Notebook
hoxbro 3914656
update datashader test
hoxbro 8f71e46
Updates to pass raster tests
hoxbro 2775f06
Update lookup data in _update_hover
hoxbro 042b695
Have selector_columns
hoxbro 9a4b0d6
Make mixin
hoxbro b2a65f1
Add Server Hover for datashade element
hoxbro f1e9f10
Support self.hover_tooltips
hoxbro ca34132
fix: when no data is selected
hoxbro 2fbd755
Add back __index__
hoxbro d318e6d
Align datashade.add_selector_data
hoxbro c0bdd6e
Add first implementation of dynspread
hoxbro b9931a6
Add valueerror if selector contains any kdims.
hoxbro ec8e6ae
dynspread for image
hoxbro aedb7b2
simplify for-loop
hoxbro ed433d8
Refactor sel_data in spread
hoxbro 2f0e822
Merge branch 'main' into inspect
hoxbro 201b75c
Add test for xarray_dataset with alpha
hoxbro 24b2fc0
clean up holoviews/tests/ui/bokeh/test_hover.py
hoxbro 4a6feca
add test for datashade and selector
hoxbro 9612f21
increase size of selector test
hoxbro e6574eb
Add test for column name matches RGBA
hoxbro 98dfb3b
Update to use inputs
hoxbro 394c873
dont overwrite existing data in dynspread with selector
hoxbro 002f392
Add test for spread with selector
hoxbro 356007b
rename selector test
hoxbro 04ae8de
extra assert for xarray dataset
hoxbro f36a4ab
Add UI test for hovertool
hoxbro 8804bfb
Remove anti-pattern wait_until + expect
hoxbro 43741fb
Move import into ui test
hoxbro fb4ddd1
fix unrelated error message
hoxbro 10d4272
Add rasterize support for ds.by and selector
hoxbro 5b9bc46
Use hold for server too
hoxbro 909577a
Add pytest fixture rng
hoxbro 4e00229
Merge branch 'main' into inspect
hoxbro 8fe1e26
remove anti-pattern wait_until(lambda: expect(...))
hoxbro 27d9ed2
Update AggState
hoxbro 8f4ce24
Fix problem with datashade + ds.by + selector
hoxbro a24e561
misc small changes
hoxbro 31e30f5
Merge branch 'main' into inspect
hoxbro 725d46b
Revert dims change
hoxbro 9b5342b
Merge branch 'main' into inspect
hoxbro db0cdcb
Use data.sizes for transpose
hoxbro 21e3f3e
Small changes
hoxbro f0e513c
Update test
hoxbro 99b32db
Add support for dynspread + rasterize + ds.by
hoxbro c636bd5
Join Image + ImageStack
hoxbro 4eb0189
add spreading test for rasterize/datashade + ds.by
hoxbro 08dc4b1
Test that x and y has a value
hoxbro 9e71910
Set alpha to 0 for all dimensions when converting form uint32 to 4 uint8
hoxbro c139777
Update test to match alpha=0 change
hoxbro f4dd02d
expr_state -> agg_state
hoxbro e818c4d
Check for hv_created in tool tags
hoxbro 3922f79
Merge branch 'main' into inspect
hoxbro 8f4378e
refactor alpha dimension detection
hoxbro 48f91a5
Remove RGBA in hover
hoxbro File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
import enum | ||
import warnings | ||
from collections.abc import Callable, Iterable | ||
from functools import partial | ||
|
@@ -238,6 +239,25 @@ class LineAggregationOperation(AggregationOperation): | |
|
||
|
||
|
||
class AggState(enum.Enum): | ||
AGG_ONLY = 0 # Only aggregator | ||
AGG_BY = 1 # Aggregator where the aggregator is ds.by | ||
AGG_SEL = 2 # Selector and aggregator | ||
AGG_SEL_BY = 3 # Selector and aggregator, where the aggregator is ds.by | ||
|
||
def get_state(agg_fn, sel_fn): | ||
if isinstance(agg_fn, ds.by): | ||
return AggState.AGG_SEL_BY if sel_fn else AggState.AGG_BY | ||
else: | ||
return AggState.AGG_SEL if sel_fn else AggState.AGG_ONLY | ||
|
||
def has_sel(state): | ||
return state in (AggState.AGG_SEL, AggState.AGG_SEL_BY) | ||
|
||
def has_by(state): | ||
return state in (AggState.AGG_BY, AggState.AGG_SEL_BY) | ||
|
||
|
||
class aggregate(LineAggregationOperation): | ||
""" | ||
aggregate implements 2D binning for any valid HoloViews Element | ||
|
@@ -391,16 +411,14 @@ def _process(self, element, key=None): | |
dfdata = PandasInterface.as_dframe(data) | ||
cvs_fn = getattr(cvs, glyph) | ||
|
||
if sel_fn: | ||
expr_state = AggState.get_state(agg_fn, sel_fn) | ||
if AggState.has_sel(expr_state): | ||
if isinstance(params["vdims"], (Dimension, str)): | ||
params["vdims"] = [params["vdims"]] | ||
sum_agg = ds.summary(**{str(params["vdims"][0]): agg_fn, "__index__": ds.where(sel_fn)}) | ||
agg = self._apply_datashader(dfdata, cvs_fn, sum_agg, agg_kwargs, x, y) | ||
_ignore = [*params["vdims"], "__index__"] | ||
sel_vdims = [s for s in agg if s not in _ignore] | ||
params["vdims"] = [*params["vdims"], *sel_vdims] | ||
agg = self._apply_datashader(dfdata, cvs_fn, sum_agg, agg_kwargs, x, y, expr_state) | ||
else: | ||
agg = self._apply_datashader(dfdata, cvs_fn, agg_fn, agg_kwargs, x, y) | ||
agg = self._apply_datashader(dfdata, cvs_fn, agg_fn, agg_kwargs, x, y, expr_state) | ||
|
||
if 'x_axis' in agg.coords and 'y_axis' in agg.coords: | ||
agg = agg.rename({'x_axis': x, 'y_axis': y}) | ||
|
@@ -409,14 +427,16 @@ def _process(self, element, key=None): | |
if ytype == 'datetime': | ||
agg[y.name] = agg[y.name].astype('datetime64[ns]') | ||
|
||
if isinstance(agg, xr.Dataset) or agg.ndim == 2: | ||
# Replacing x and y coordinates to avoid numerical precision issues | ||
if not AggState.has_by(expr_state): | ||
return self.p.element_type(agg, **params) | ||
else: | ||
elif expr_state == AggState.AGG_BY: | ||
params['vdims'] = list(map(str, agg.coords[agg_fn.column].data)) | ||
return ImageStack(agg, **params) | ||
elif expr_state == AggState.AGG_SEL_BY: | ||
params['vdims'] = [d for d in agg.data_vars if d not in agg.attrs["selector_columns"]] | ||
return ImageStack(agg, **params) | ||
|
||
def _apply_datashader(self, dfdata, cvs_fn, agg_fn, agg_kwargs, x, y): | ||
def _apply_datashader(self, dfdata, cvs_fn, agg_fn, agg_kwargs, x, y, agg_state: AggState): | ||
# Suppress numpy warning emitted by dask: | ||
# https://github.com/dask/dask/issues/8439 | ||
with warnings.catch_warnings(): | ||
|
@@ -427,18 +447,26 @@ def _apply_datashader(self, dfdata, cvs_fn, agg_fn, agg_kwargs, x, y): | |
agg = cvs_fn(dfdata, x.name, y.name, agg_fn, **agg_kwargs) | ||
|
||
is_where_index = DATASHADER_GE_0_15_1 and isinstance(agg_fn, ds.where) and isinstance(agg_fn.column, rd.SpecialColumn) | ||
is_summary_index = isinstance(agg_fn, ds.summary) and "__index__" in agg | ||
is_summary_index = AggState.has_sel(agg_state) | ||
if is_where_index or is_summary_index: | ||
if is_where_index: | ||
data = agg.data | ||
index = agg.data | ||
agg = agg.to_dataset(name="__index__") | ||
else: # summary index | ||
data = agg["__index__"].data | ||
neg1 = data == -1 | ||
index = agg["__index__"].data | ||
if agg_state == AggState.AGG_SEL_BY: | ||
main_dim = next(k for k in agg if k != "__index__") | ||
# Taking values from the main dimension expanding it to | ||
# a new dataset | ||
agg = agg[main_dim].to_dataset(dim=list(agg.sizes)[2]) | ||
agg["__index__"] = ((y.name, x.name), index) | ||
|
||
neg1 = index == -1 | ||
agg.attrs["selector_columns"] = sel_cols = ["__index__"] | ||
for col in dfdata.columns: | ||
if col in agg.coords: | ||
continue | ||
val = dfdata[col].values[data] | ||
val = dfdata[col].values[index] | ||
if val.dtype.kind == 'f': | ||
val[neg1] = np.nan | ||
elif isinstance(val.dtype, pd.CategoricalDtype): | ||
|
@@ -452,8 +480,9 @@ def _apply_datashader(self, dfdata, cvs_fn, agg_fn, agg_kwargs, x, y): | |
val = val.astype(np.float64) | ||
val[neg1] = np.nan | ||
agg[col] = ((y.name, x.name), val) | ||
sel_cols.append(col) | ||
|
||
if isinstance(agg_fn, ds.by): | ||
if agg_state == AggState.AGG_BY: | ||
col = agg_fn.column | ||
if '' in agg.coords[col]: | ||
agg = agg.drop_sel(**{col: ''}) | ||
|
@@ -1234,7 +1263,9 @@ def uint32_to_uint8(cls, img): | |
""" | ||
Cast uint32 RGB image to 4 uint8 channels. | ||
""" | ||
return np.flipud(img.view(dtype=np.uint8).reshape((*img.shape, 4))) | ||
new_array = np.flipud(img.view(dtype=np.uint8).reshape((*img.shape, 4))) | ||
new_array[new_array[:,:,3] == 0] = 0 # Set alpha 0 to 0 for all dimension | ||
philippjfr marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return new_array | ||
|
||
|
||
@classmethod | ||
|
@@ -1243,6 +1274,7 @@ def uint32_to_uint8_xr(cls, img): | |
Cast uint32 xarray DataArray to 4 uint8 channels. | ||
""" | ||
new_array = img.values.view(dtype=np.uint8).reshape((*img.shape, 4)) | ||
new_array[new_array[:,:,3] == 0] = 0 | ||
coords = dict(img.coords, band=[0, 1, 2, 3]) | ||
return xr.DataArray(new_array, coords=coords, dims=(*img.dims, 'band')) | ||
|
||
|
@@ -1277,6 +1309,33 @@ def to_xarray(cls, element): | |
xdensity=element.xdensity, | ||
ydensity=element.ydensity) | ||
|
||
@classmethod | ||
def _extract_data(self, element): | ||
vdims = element.vdims | ||
vdim = vdims[0].name if len(vdims) == 1 else None | ||
if isinstance(element, ImageStack): | ||
array = element.data | ||
main_dims = element.data.sizes | ||
# Dropping data related to selector columns | ||
if sel_cols := array.attrs.get("selector_columns"): | ||
array = array.drop_vars(sel_cols) | ||
# If data is a xarray Dataset it has to be converted to a | ||
# DataArray, either by selecting the singular value | ||
# dimension or by adding a z-dimension | ||
if not element.interface.packed(element): | ||
if vdim: | ||
array = array[vdim] | ||
else: | ||
array = array.to_array("z") | ||
# If data is 3D then we have one extra constant dimension | ||
if array.ndim > 3: | ||
drop = set(array.dims) - {*main_dims, 'z'} | ||
array = array.squeeze(dim=drop) | ||
array = array.transpose(*main_dims, ...) | ||
else: | ||
array = element.data[vdim] | ||
|
||
return array | ||
|
||
def _process(self, element, key=None): | ||
element = element.map(self.to_xarray, Image) | ||
|
@@ -1297,26 +1356,7 @@ def _process(self, element, key=None): | |
element = element.clone(datatype=['xarray']) | ||
|
||
kdims = element.kdims | ||
if isinstance(element, ImageStack): | ||
vdim = element.vdims | ||
array = element.data | ||
# If data is a xarray Dataset it has to be converted to a | ||
# DataArray, either by selecting the singular value | ||
# dimension or by adding a z-dimension | ||
kdims = [kdim.name for kdim in kdims] | ||
if not element.interface.packed(element): | ||
if len(vdim) == 1: | ||
array = array[vdim[0].name] | ||
else: | ||
array = array.to_array("z") | ||
# If data is 3D then we have one extra constant dimension | ||
if array.ndim > 3: | ||
drop = [d for d in array.dims if d not in [*kdims, 'z']] | ||
array = array.squeeze(dim=drop) | ||
array = array.transpose(*kdims, ...) | ||
else: | ||
vdim = element.vdims[0].name | ||
array = element.data[vdim] | ||
array = self._extract_data(element) | ||
|
||
# Dask is not supported by shade so materialize it | ||
array = array.compute() | ||
|
@@ -1372,12 +1412,26 @@ def _process(self, element, key=None): | |
coords = {xd.name: element.data.coords[xd.name], | ||
yd.name: element.data.coords[yd.name], | ||
'band': [0, 1, 2, 3]} | ||
img = xr.DataArray(arr, coords=coords, dims=(yd.name, xd.name, 'band')) | ||
return RGB(img, **params) | ||
img_data = xr.DataArray(arr, coords=coords, dims=(yd.name, xd.name, 'band')) | ||
img_data = self.add_selector_data(img_data=img_data, sel_data=element.data) | ||
return RGB(img_data, **params) | ||
else: | ||
img = tf.shade(array, **shade_opts) | ||
return RGB(self.uint32_to_uint8_xr(img), **params) | ||
img_data = self.uint32_to_uint8_xr(img) | ||
img_data = self.add_selector_data(img_data=img_data, sel_data=element.data) | ||
return RGB(img_data, **params) | ||
|
||
@classmethod | ||
def add_selector_data(cls, *, img_data, sel_data): | ||
if "selector_columns" in sel_data.attrs: | ||
if {"R", "G", "B", "A"} & set(sel_data.attrs["selector_columns"]): | ||
msg = "Cannot use 'R', 'G', 'B', or 'A' as columns, when using datashade with selector" | ||
raise ValueError(msg) | ||
img_data.coords["band"] = ["R", "G", "B", "A"] | ||
img_data = img_data.to_dataset(dim="band") | ||
img_data.update({k: sel_data[k] for k in sel_data.attrs["selector_columns"]}) | ||
img_data.attrs["selector_columns"] = sel_data.attrs["selector_columns"] | ||
return img_data | ||
|
||
|
||
class geometry_rasterize(LineAggregationOperation): | ||
|
@@ -1654,7 +1708,7 @@ def uint8_to_uint32(cls, img): | |
rgb = img.reshape((flat_shape, 4)).view('uint32').reshape(shape[:2]) | ||
return rgb | ||
|
||
def _apply_spreading(self, array): | ||
def _apply_spreading(self, array, how=None): | ||
"""Apply the spread function using the indicated parameters.""" | ||
raise NotImplementedError | ||
|
||
|
@@ -1669,16 +1723,50 @@ def _process(self, element, key=None): | |
if isinstance(element, RGB): | ||
rgb = element.rgb | ||
data = self._preprocess_rgb(rgb) | ||
elif isinstance(element, ImageStack): | ||
data = element.data | ||
elif isinstance(element, Image): | ||
data = element.clone(datatype=['xarray']).data[element.vdims[0].name] | ||
if element.interface.datatype != 'xarray': | ||
element = element.clone(datatype=['xarray']) | ||
data = shade._extract_data(element) | ||
philippjfr marked this conversation as resolved.
Show resolved
Hide resolved
|
||
else: | ||
raise ValueError('spreading can only be applied to Image or RGB Elements. ' | ||
f'Received object of type {type(element)!s}') | ||
|
||
kwargs = {} | ||
array = self._apply_spreading(data) | ||
if "selector_columns" in getattr(element.data, "attrs", ()): | ||
new_data = element.data.copy() | ||
index = new_data["__index__"].copy() | ||
mask = np.arange(index.size).reshape(index.shape) | ||
mask[index == -1] = 0 | ||
index.data = mask | ||
index = self._apply_spreading(index, how="source") | ||
sel_data = { | ||
sc: new_data[sc].data.ravel()[index].reshape(index.shape) | ||
for sc in new_data.attrs["selector_columns"] | ||
} | ||
|
||
if isinstance(element, RGB): | ||
img = datashade.uint32_to_uint8(array.data)[::-1] | ||
for idx, k, in enumerate("RGBA"): | ||
new_data[k].data = img[:, :, idx] | ||
elif isinstance(element, ImageStack): | ||
for k in map(str, element.vdims): | ||
new_data[k].data = array.sel(z=k) | ||
elif isinstance(element, Image): | ||
new_data[element.vdims[0].name].data = array | ||
else: | ||
msg = f"{type(element).__name__} currently does not support spreading with selector_columns" | ||
raise NotImplementedError(msg) | ||
|
||
for k, v in sel_data.items(): | ||
new_data[k].data = v | ||
|
||
# TODO: Investigate why this does not work | ||
# element = element.clone(data=new_data, kdims=element.vdims.copy(), vdims=element.vdims.copy()) | ||
element = element.clone() | ||
element.data = new_data | ||
Comment on lines
+1763
to
+1766
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't understand this. |
||
return element | ||
|
||
kwargs = {} | ||
if isinstance(element, RGB): | ||
img = datashade.uint32_to_uint8(array.data)[::-1] | ||
new_data = { | ||
|
@@ -1710,8 +1798,8 @@ class spread(SpreadingOperation): | |
px = param.Integer(default=1, doc=""" | ||
Number of pixels to spread on all sides.""") | ||
|
||
def _apply_spreading(self, array): | ||
return tf.spread(array, px=self.p.px, how=self.p.how, shape=self.p.shape) | ||
def _apply_spreading(self, array, how=None): | ||
return tf.spread(array, px=self.p.px, how=how or self.p.how, shape=self.p.shape) | ||
|
||
|
||
class dynspread(SpreadingOperation): | ||
|
@@ -1737,10 +1825,10 @@ class dynspread(SpreadingOperation): | |
Higher values give more spreading, up to the max_px | ||
allowed.""") | ||
|
||
def _apply_spreading(self, array): | ||
def _apply_spreading(self, array, how=None): | ||
return tf.dynspread( | ||
array, max_px=self.p.max_px, threshold=self.p.threshold, | ||
how=self.p.how, shape=self.p.shape | ||
how=how or self.p.how, shape=self.p.shape | ||
) | ||
|
||
|
||
|
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not that it wasn't already but that's getting quite unwieldy.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In that case please refactor 🙂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure what you mean. Do you want it to be put into a method?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Refactored in 8f4378e
Which is what I think you mean