Skip to content
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

Handle datetimes and empty selections better for inspect operations #6377

Merged
merged 6 commits into from
Sep 26, 2024
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
85 changes: 55 additions & 30 deletions holoviews/operation/datashader.py
Original file line number Diff line number Diff line change
Expand Up @@ -1784,9 +1784,10 @@ class inspect_mask(Operation):
operation.
"""

pixels = param.Integer(default=3, doc="""
pixels = param.ClassSelector(default=3, class_=(int, tuple), doc="""
Size of the mask that should match the pixels parameter used in
the associated inspection operation.""")
the associated inspection operation. Pixels can be provided as
integer or x/y-tuple to perform asymmetric masking.""")

streams = param.ClassSelector(default=[PointerXY], class_=(dict, list))
x = param.Number(default=0)
Expand All @@ -1795,9 +1796,13 @@ class inspect_mask(Operation):
@classmethod
def _distance_args(cls, element, x_range, y_range, pixels):
ycount, xcount = element.interface.shape(element, gridded=True)
if isinstance(pixels, tuple):
xpixels, ypixels = pixels
else:
xpixels = ypixels = pixels
x_delta = abs(x_range[1] - x_range[0]) / xcount
y_delta = abs(y_range[1] - y_range[0]) / ycount
return (x_delta*pixels, y_delta*pixels)
return (x_delta*xpixels, y_delta*ypixels)

def _process(self, raster, key=None):
if isinstance(raster, RGB):
Expand All @@ -1820,10 +1825,11 @@ class inspect(Operation):
type.
"""

pixels = param.Integer(default=3, doc="""
pixels = param.ClassSelector(default=3, class_=(int, tuple), doc="""
Number of pixels in data space around the cursor point to search
for hits in. The hit within this box mask that is closest to the
cursor's position is displayed.""")
cursor's position is displayed. Pixels can be provided as
integer or x/y-tuple to perform asymmetric masking.""")

null_value = param.Number(default=0, doc="""
Value of raster which indicates no hits. For instance zero for
Expand Down Expand Up @@ -1856,9 +1862,9 @@ class inspect(Operation):
y=PointerXY.param.y),
class_=(dict, list))

x = param.Number(default=0, doc="x-position to inspect.")
x = param.Number(default=None, doc="x-position to inspect.")

y = param.Number(default=0, doc="y-position to inspect.")
y = param.Number(default=None, doc="y-position to inspect.")

_dispatch = {}

Expand Down Expand Up @@ -1909,33 +1915,40 @@ class inspect_base(inspect):

def _process(self, raster, key=None):
self._validate(raster)
if isinstance(raster, RGB):
raster = raster[..., raster.vdims[-1]]
x_range, y_range = raster.range(0), raster.range(1)
xdelta, ydelta = self._distance_args(raster, x_range, y_range, self.p.pixels)
x, y = self.p.x, self.p.y
val = raster[x-xdelta:x+xdelta, y-ydelta:y+ydelta].reduce(function=np.nansum)
if np.isnan(val):
val = self.p.null_value

if ((self.p.value_bounds and
not (self.p.value_bounds[0] < val < self.p.value_bounds[1]))
or val == self.p.null_value):
result = self._empty_df(raster.dataset)
else:
masked = self._mask_dataframe(raster, x, y, xdelta, ydelta)
result = self._sort_by_distance(raster, masked, x, y)
if x is not None and y is not None:
if isinstance(raster, RGB):
raster = raster[..., raster.vdims[-1]]
x_range, y_range = raster.range(0), raster.range(1)
xdelta, ydelta = self._distance_args(raster, x_range, y_range, self.p.pixels)
val = raster[x-xdelta:x+xdelta, y-ydelta:y+ydelta].reduce(function=np.nansum)
if np.isnan(val):
val = self.p.null_value

if ((self.p.value_bounds and
not (self.p.value_bounds[0] < val < self.p.value_bounds[1]))
or val == self.p.null_value):
result = self._empty_df(raster.dataset)
else:
masked = self._mask_dataframe(raster, x, y, xdelta, ydelta)
result = self._sort_by_distance(raster, masked, x, y)

self.hits = result
self.hits = result
else:
self.hits = result = self._empty_df(raster.dataset)
df = self.p.transform(result)
return self._element(raster, df.iloc[:self.p.max_indicators])

@classmethod
def _distance_args(cls, element, x_range, y_range, pixels):
ycount, xcount = element.interface.shape(element, gridded=True)
def _distance_args(cls, element, x_range, y_range, pixels):
ycount, xcount = element.interface.shape(element, gridded=True)
if isinstance(pixels, tuple):
xpixels, ypixels = pixels
else:
xpixels = ypixels = pixels
x_delta = abs(x_range[1] - x_range[0]) / xcount
y_delta = abs(y_range[1] - y_range[0]) / ycount
return (x_delta*pixels, y_delta*pixels)
return (x_delta*xpixels, y_delta*ypixels)

@classmethod
def _empty_df(cls, dataset):
Expand Down Expand Up @@ -1990,9 +2003,21 @@ def _sort_by_distance(cls, raster, df, x, y):
ds = raster.dataset.clone(df)
xs, ys = (ds.dimension_values(kd) for kd in raster.kdims)
dx, dy = xs - x, ys - y
distances = pd.Series(dx*dx + dy*dy)
return df.iloc[distances.argsort().values]

xtype, ytype = dx.dtype.kind, dy.dtype.kind
if xtype in 'Mm':
dx = dx.astype('int64')
if ytype in 'Mm':
dy = dx.astype('int64')
# If coordinate types don't match normalize
# coordinate space to ensure that distance
# in both direction is handled the same.
if xtype != ytype and len(dx) and len(dy):
with warnings.catch_warnings():
warnings.filterwarnings('ignore', r'invalid value encountered in (divide)')
philippjfr marked this conversation as resolved.
Show resolved Hide resolved
dx = (dx - dx.min()) / (dx.max() - dx.min())
dy = (dy - dy.min()) / (dy.max() - dy.min())
distances = pd.Series(dx**2 + dy**2)
philippjfr marked this conversation as resolved.
Show resolved Hide resolved
return df.iloc[distances.fillna(0).argsort().values]


class inspect_polygons(inspect_base):
Expand Down Expand Up @@ -2026,7 +2051,7 @@ def _sort_by_distance(cls, raster, df, x, y):
xs.append((np.min(gxs)+np.max(gxs))/2)
ys.append((np.min(gys)+np.max(gys))/2)
dx, dy = np.array(xs) - x, np.array(ys) - y
distances = pd.Series(dx*dx + dy*dy)
distances = pd.Series(dx**2 + dy**2)
return df.iloc[distances.argsort().values]


Expand Down
29 changes: 25 additions & 4 deletions holoviews/tests/operation/test_datashader.py
Original file line number Diff line number Diff line change
Expand Up @@ -1442,14 +1442,25 @@ def test_directly_connect_paths(self):
direct = directly_connect_edges(self.graph)._split_edgepaths
self.assertEqual(direct, self.graph.edgepaths)


class InspectorTests(ComparisonTestCase):
"""
Tests for inspector operations
"""
def setUp(self):
points = Points([(0.2, 0.3), (0.4, 0.7), (0, 0.99)])
self.pntsimg = rasterize(points, dynamic=False,
x_range=(0, 1), y_range=(0, 1), width=4, height=4)
self.pntsimg = rasterize(
points, dynamic=False, height=4, width=4,
x_range=(0, 1), y_range=(0, 1)
)
date_pts = Points([
(np.datetime64('2024-09-25 11:00'), 0.3),
(np.datetime64('2024-09-25 11:01'), 0.7),
(np.datetime64('2024-09-25 11:04'), 0.99)])
self.datesimg = rasterize(
date_pts, dynamic=False, height=4, width=4,
x_range=(np.datetime64('2024-09-25 11:00'), np.datetime64('2024-09-25 11:04')), y_range=(0, 1)
)
if spatialpandas is None:
return

Expand All @@ -1464,7 +1475,6 @@ def setUp(self):
def tearDown(self):
Tap.x, Tap.y = None, None


def test_inspect_points_or_polygons(self):
if spatialpandas is None:
raise SkipTest('Polygon inspect tests require spatialpandas')
Expand Down Expand Up @@ -1521,6 +1531,18 @@ def test_points_inspection_dict_streams_instance(self):
self.assertEqual(points.streams[0].x, 0.2)
self.assertEqual(points.streams[0].y, 0.3)

def test_points_with_dates_inspection_1px_mask(self):
points = inspect_points(self.datesimg, max_indicators=3, dynamic=False, pixels=1,
x=np.datetime64('2024-09-25 11:01'), y=-0.1)
self.assertEqual(points.dimension_values('x'), np.array([]))
self.assertEqual(points.dimension_values('y'), np.array([]))

def test_points_with_dates_inspection_2px_mask(self):
points = inspect_points(self.datesimg, max_indicators=3, dynamic=False, pixels=2,
x=np.datetime64('2024-09-25 11:01'), y=-0.1)
self.assertEqual(points.dimension_values('x'), np.array([np.datetime64('2024-09-25 11:00')]))
self.assertEqual(points.dimension_values('y'), np.array([0.3]))

def test_polys_inspection_1px_mask_hit(self):
if spatialpandas is None:
raise SkipTest('Polygon inspect tests require spatialpandas')
Expand All @@ -1540,7 +1562,6 @@ def test_inspection_1px_mask_poly_df(self):
self.assertEqual(inspector.hits.iloc[0].geometry,
spatialpandas.geometry.polygon.Polygon(data))


def test_polys_inspection_1px_mask_miss(self):
if spatialpandas is None:
raise SkipTest('Polygon inspect tests require spatialpandas')
Expand Down