From 1a6fa7467c7d6fe7b1deec5b84c054ea3a23813a Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 18 Sep 2024 16:42:24 +0200 Subject: [PATCH 1/6] Handle datetimes and empty selections better inspect operations --- holoviews/operation/datashader.py | 63 ++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py index b24d28c7fd..d30fec3b05 100644 --- a/holoviews/operation/datashader.py +++ b/holoviews/operation/datashader.py @@ -1856,9 +1856,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,30 +1909,39 @@ 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) + arr = raster[ + x-xdelta:x+xdelta, y-ydelta:y+ydelta + ].dimension_values(2, flat=False) + if arr.size: + val = np.nansum(arr) + else: + val = np.nan + 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) 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) @@ -1990,11 +1999,19 @@ 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) + if dx.dtype.kind in 'Mm': + if dx.dtype != dy.dtype and len(dx): + dx = dx.astype('int64') + dx = (dx - dx.min()) / (dx.max() - dx.min()) + dy = (dy - dy.min()) / (dy.max() - dy.min()) + else: + dx = dx.astype('int64') + if dy.dtype.kind in 'Mm': + dy = dx.astype('int64') + distances = pd.Series(dx**2 + dy**2) return df.iloc[distances.argsort().values] - class inspect_polygons(inspect_base): @classmethod @@ -2026,7 +2043,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] From 077a346f8b412ebf439825723c72a473b852ab14 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 18 Sep 2024 16:44:25 +0200 Subject: [PATCH 2/6] Resimplify --- holoviews/operation/datashader.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py index d30fec3b05..e777daa246 100644 --- a/holoviews/operation/datashader.py +++ b/holoviews/operation/datashader.py @@ -1915,13 +1915,7 @@ def _process(self, raster, key=None): 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) - arr = raster[ - x-xdelta:x+xdelta, y-ydelta:y+ydelta - ].dimension_values(2, flat=False) - if arr.size: - val = np.nansum(arr) - else: - val = np.nan + val = raster[x-xdelta:x+xdelta, y-ydelta:y+ydelta].reduce(function=np.nansum) if np.isnan(val): val = self.p.null_value From 55f7eb1d1ad8777378bf1685113bf186bb94cf37 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 19 Sep 2024 12:45:45 +0200 Subject: [PATCH 3/6] Allow assymetric pixels --- holoviews/operation/datashader.py | 40 ++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py index e777daa246..83408adee3 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 @@ -1936,9 +1942,13 @@ def _process(self, raster, key=None): @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) @classmethod def _empty_df(cls, dataset): @@ -1993,15 +2003,17 @@ 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 - if dx.dtype.kind in 'Mm': - if dx.dtype != dy.dtype and len(dx): - dx = dx.astype('int64') - dx = (dx - dx.min()) / (dx.max() - dx.min()) - dy = (dy - dy.min()) / (dy.max() - dy.min()) - else: - dx = dx.astype('int64') - if dy.dtype.kind in 'Mm': + xtype, ytype = dx.dtype.kind, dy.dtype.kind + if xtype.kind in 'Mm': + dx = dx.astype('int64') + if ytype.kind 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): + 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.argsort().values] From 5a67f9e0681868af567867781d7504c95fef9c12 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 25 Sep 2024 11:40:36 +0200 Subject: [PATCH 4/6] Add tests --- holoviews/operation/datashader.py | 11 +++++--- holoviews/tests/operation/test_datashader.py | 29 +++++++++++++++++--- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py index 83408adee3..6abf1c3d20 100644 --- a/holoviews/operation/datashader.py +++ b/holoviews/operation/datashader.py @@ -2004,17 +2004,20 @@ def _sort_by_distance(cls, raster, df, x, y): xs, ys = (ds.dimension_values(kd) for kd in raster.kdims) dx, dy = xs - x, ys - y xtype, ytype = dx.dtype.kind, dy.dtype.kind - if xtype.kind in 'Mm': + if xtype in 'Mm': dx = dx.astype('int64') - if ytype.kind in 'Mm': + 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): - dx = (dx - dx.min()) / (dx.max() - dx.min()) - dy = (dy - dy.min()) / (dy.max() - dy.min()) + 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) + distances[distances.isna()] = 0 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') From 0c18ef1fb133fb38eb77c105ec0cbfce1077386b Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 25 Sep 2024 19:01:29 +0200 Subject: [PATCH 5/6] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Simon Høxbro Hansen --- holoviews/operation/datashader.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py index 6abf1c3d20..10f1a38f46 100644 --- a/holoviews/operation/datashader.py +++ b/holoviews/operation/datashader.py @@ -2012,13 +2012,11 @@ def _sort_by_distance(cls, raster, df, x, y): # 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)') + with np.errstate(divide='ignore'): dx = (dx - dx.min()) / (dx.max() - dx.min()) dy = (dy - dy.min()) / (dy.max() - dy.min()) distances = pd.Series(dx**2 + dy**2) - distances[distances.isna()] = 0 - return df.iloc[distances.argsort().values] + return df.iloc[distances.fillna(0).argsort().values] class inspect_polygons(inspect_base): From c1fac798534289b086bdf188e59adc38c02d03bb Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 26 Sep 2024 12:04:29 +0200 Subject: [PATCH 6/6] Apply suggestions from code review --- holoviews/operation/datashader.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/holoviews/operation/datashader.py b/holoviews/operation/datashader.py index 10f1a38f46..2e1a64143b 100644 --- a/holoviews/operation/datashader.py +++ b/holoviews/operation/datashader.py @@ -2012,7 +2012,8 @@ def _sort_by_distance(cls, raster, df, x, y): # coordinate space to ensure that distance # in both direction is handled the same. if xtype != ytype and len(dx) and len(dy): - with np.errstate(divide='ignore'): + 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)