diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py index b24d28c7fd..2e1a64143b 100644 --- a/holoviews/operation/datashader.py +++ b/holoviews/operation/datashader.py @@ -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) @@ -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): @@ -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 @@ -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 = {} @@ -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): @@ -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)') + dx = (dx - dx.min()) / (dx.max() - dx.min()) + dy = (dy - dy.min()) / (dy.max() - dy.min()) + distances = pd.Series(dx**2 + dy**2) + return df.iloc[distances.fillna(0).argsort().values] class inspect_polygons(inspect_base): @@ -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] diff --git a/holoviews/tests/operation/test_datashader.py b/holoviews/tests/operation/test_datashader.py index 5feb8906cb..4f05e4ad30 100644 --- a/holoviews/tests/operation/test_datashader.py +++ b/holoviews/tests/operation/test_datashader.py @@ -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 @@ -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') @@ -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') @@ -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')