diff --git a/holoviews/operation/element.py b/holoviews/operation/element.py index c1d43c8445..535c86fd5b 100644 --- a/holoviews/operation/element.py +++ b/holoviews/operation/element.py @@ -571,6 +571,7 @@ def _process(self, element, key=None): if any(data_is_datetime[:2]) and self.p.filled: raise RuntimeError("Datetime spatial coordinates are not supported " "for filled contour calculations.") + try: from matplotlib.dates import num2date, date2num except ImportError: @@ -602,7 +603,6 @@ def _process(self, element, key=None): if data_is_datetime[2]: levels = date2num(levels) - # Should check isdatetime(levels) too? crange = levels.min(), levels.max() if self.p.filled: @@ -639,14 +639,14 @@ def points_to_datetime(points): outer_offsets = filled[2][0] exteriors = [] interiors = [] - for i in range(len(outer_offsets)-1): # Loop through exterior boundaries - jstart = outer_offsets[i] # Start boundary index - jend = outer_offsets[i+1] # End boundary index + + # Loop through exterior polygon boundaries. + for jstart, jend in zip(outer_offsets[:-1], outer_offsets[1:]): if exteriors: exteriors.append(empty) exteriors.append(points[offsets[jstart]:offsets[jstart + 1]]) - # Loop over the (jend-jstart-1) interior boundaries, + # Loop over the (jend-jstart-1) interior boundaries. interior = [points[offsets[j]:offsets[j + 1]] for j in range(jstart+1, jend)] interiors.append(interior) level = (lower_level + upper_level) / 2 diff --git a/holoviews/tests/operation/test_operation.py b/holoviews/tests/operation/test_operation.py index b84e22f70f..57e1b23aff 100644 --- a/holoviews/tests/operation/test_operation.py +++ b/holoviews/tests/operation/test_operation.py @@ -74,16 +74,17 @@ def test_image_gradient(self): self.assertEqual(op_img, img.clone(np.array([[3.162278, 3.162278], [3.162278, 3.162278]]), group='Gradient')) def test_image_contours(self): - img = Image(np.array([[0, 1, 0], [3, 4, 5.], [6, 7, 8]])) + img = Image(np.array([[0, 1, 0], [0, 1, 0]])) op_contours = contours(img, levels=[0.5]) - contour = Contours([[(-0.166667, 0.333333, 0.5), (-0.333333, 0.277778, 0.5), - (np.NaN, np.NaN, 0.5), (0.333333, 0.3, 0.5), - (0.166667, 0.333333, 0.5)]], + # Note multiple lines which are NaN-separated. + contour = Contours([[(-0.166667, 0.25, 0.5), (-0.1666667, -0.25, 0.5), + (np.NaN, np.NaN, 0.5), (0.1666667, -0.25, 0.5), + (0.1666667, 0.25, 0.5)]], vdims=img.vdims) self.assertEqual(op_contours, contour) def test_image_contours_empty(self): - img = Image(np.array([[0, 1, 0], [3, 4, 5.], [6, 7, 8]])) + img = Image(np.array([[0, 1, 0], [0, 1, 0]])) # Contour level outside of data limits op_contours = contours(img, levels=[23.0]) contour = Contours(None, vdims=img.vdims) @@ -102,46 +103,81 @@ def test_image_contours_no_range(self): self.assertEqual(op_contours, contour) def test_image_contours_x_datetime(self): - x = np.array(['2023-09-01', '2023-09-02'], dtype='datetime64') + x = np.array(['2023-09-01', '2023-09-03', '2023-09-05'], dtype='datetime64') y = [14, 15] - z = np.array([[0, 1], [1, 2]]) + z = np.array([[0, 1, 0], [0, 1, 0]]) img = Image((x, y, z)) op_contours = contours(img, levels=[0.5]) - expected_x = np.array([dt.datetime(2023, 9, 1, tzinfo=dt.timezone.utc), - dt.datetime(2023, 9, 1, 12, tzinfo=dt.timezone.utc)], - dtype=object) - np.testing.assert_array_equal(op_contours.dimension_values('x'), expected_x) - np.testing.assert_array_almost_equal(op_contours.dimension_values('y'), [14.5, 14.0]) - np.testing.assert_array_almost_equal(op_contours.dimension_values('z'), [0.5, 0.5]) + # Note multiple lines which are nan-separated. + tz = dt.timezone.utc + expected_x = np.array( + [dt.datetime(2023, 9, 2, tzinfo=tz), dt.datetime(2023, 9, 2, tzinfo=tz), np.nan, + dt.datetime(2023, 9, 4, tzinfo=tz), dt.datetime(2023, 9, 4, tzinfo=tz)], + dtype=object) + + # Separately compare nans and datetimes + x = op_contours.dimension_values('x') + mask = np.array([True, True, False, True, True]) # Mask ignoring nans + np.testing.assert_array_equal(x[mask], expected_x[mask]) + np.testing.assert_array_equal(x[~mask].astype(float), expected_x[~mask].astype(float)) + + np.testing.assert_array_almost_equal(op_contours.dimension_values('y').astype(float), + [15, 14, np.nan, 14, 15]) + np.testing.assert_array_almost_equal(op_contours.dimension_values('z'), [0.5]*5) def test_image_contours_y_datetime(self): - x = [14, 15] - y = np.array(['2023-09-01', '2023-09-02'], dtype='datetime64') - z = np.array([[0, 1], [1, 2]]) + x = [14, 15, 16] + y = np.array(['2023-09-01', '2023-09-03'], dtype='datetime64') + z = np.array([[0, 1, 0], [0, 1, 0]]) img = Image((x, y, z)) op_contours = contours(img, levels=[0.5]) - np.testing.assert_array_almost_equal(op_contours.dimension_values('x'), [14.0, 14.5]) - expected_y = np.array([dt.datetime(2023, 9, 1, 12, tzinfo=dt.timezone.utc), - dt.datetime(2023, 9, 1, tzinfo=dt.timezone.utc)], - dtype=object) - np.testing.assert_array_equal(op_contours.dimension_values('y'), expected_y) - np.testing.assert_array_almost_equal(op_contours.dimension_values('z'), [0.5, 0.5]) + # Note multiple lines which are nan-separated. + np.testing.assert_array_almost_equal(op_contours.dimension_values('x').astype(float), + [14.5, 14.5, np.nan, 15.5, 15.5]) + + tz = dt.timezone.utc + expected_y = np.array( + [dt.datetime(2023, 9, 3, tzinfo=tz), dt.datetime(2023, 9, 1, tzinfo=tz), np.nan, + dt.datetime(2023, 9, 1, tzinfo=tz), dt.datetime(2023, 9, 3, tzinfo=tz)], + dtype=object) + + # Separately compare nans and datetimes + y = op_contours.dimension_values('y') + mask = np.array([True, True, False, True, True]) # Mask ignoring nans + np.testing.assert_array_equal(y[mask], expected_y[mask]) + np.testing.assert_array_equal(y[~mask].astype(float), expected_y[~mask].astype(float)) + + np.testing.assert_array_almost_equal(op_contours.dimension_values('z'), [0.5]*5) def test_image_contours_xy_datetime(self): - x = np.array(['2023-09-01', '2023-09-02'], dtype='datetime64') + x = np.array(['2023-09-01', '2023-09-03', '2023-09-05'], dtype='datetime64') y = np.array(['2023-10-07', '2023-10-08'], dtype='datetime64') - z = np.array([[0, 1], [1, 2]]) + z = np.array([[0, 1, 0], [0, 1, 0]]) img = Image((x, y, z)) op_contours = contours(img, levels=[0.5]) - expected_x = np.array([dt.datetime(2023, 9, 1, tzinfo=dt.timezone.utc), - dt.datetime(2023, 9, 1, 12, tzinfo=dt.timezone.utc)], - dtype=object) - np.testing.assert_array_equal(op_contours.dimension_values('x'), expected_x) - expected_y = np.array([dt.datetime(2023, 10, 7, 12, tzinfo=dt.timezone.utc), - dt.datetime(2023, 10, 7, tzinfo=dt.timezone.utc)], - dtype=object) - np.testing.assert_array_equal(op_contours.dimension_values('y'), expected_y) - np.testing.assert_array_almost_equal(op_contours.dimension_values('z'), [0.5, 0.5]) + # Note multiple lines which are nan-separated. + + tz = dt.timezone.utc + expected_x = np.array( + [dt.datetime(2023, 9, 2, tzinfo=tz), dt.datetime(2023, 9, 2, tzinfo=tz), np.nan, + dt.datetime(2023, 9, 4, tzinfo=tz), dt.datetime(2023, 9, 4, tzinfo=tz)], + dtype=object) + expected_y = np.array( + [dt.datetime(2023, 10, 8, tzinfo=tz), dt.datetime(2023, 10, 7, tzinfo=tz), np.nan, + dt.datetime(2023, 10, 7, tzinfo=tz), dt.datetime(2023, 10, 8, tzinfo=tz)], + dtype=object) + + # Separately compare nans and datetimes + x = op_contours.dimension_values('x') + mask = np.array([True, True, False, True, True]) # Mask ignoring nans + np.testing.assert_array_equal(x[mask], expected_x[mask]) + np.testing.assert_array_equal(x[~mask].astype(float), expected_x[~mask].astype(float)) + + y = op_contours.dimension_values('y') + np.testing.assert_array_equal(y[mask], expected_y[mask]) + np.testing.assert_array_equal(y[~mask].astype(float), expected_y[~mask].astype(float)) + + np.testing.assert_array_almost_equal(op_contours.dimension_values('z'), [0.5]*5) def test_image_contours_z_datetime(self): z = np.array([['2023-09-10', '2023-09-10'], ['2023-09-10', '2023-09-12']], dtype='datetime64') @@ -149,9 +185,9 @@ def test_image_contours_z_datetime(self): op_contours = contours(img, levels=[np.datetime64('2023-09-11')]) np.testing.assert_array_almost_equal(op_contours.dimension_values('x'), [0.25, 0.0]) np.testing.assert_array_almost_equal(op_contours.dimension_values('y'), [0.0, -0.25]) - expected_z = np.array([dt.datetime(2023, 9, 11, tzinfo=dt.timezone.utc), - dt.datetime(2023, 9, 11, tzinfo=dt.timezone.utc)], - dtype=object) + expected_z = np.array([ + dt.datetime(2023, 9, 11, 0, 0, tzinfo=dt.timezone.utc), + dt.datetime(2023, 9, 11, 0, 0, tzinfo=dt.timezone.utc)], dtype=object) np.testing.assert_array_equal(op_contours.dimension_values('z'), expected_z) def test_qmesh_contours(self): @@ -192,6 +228,16 @@ def test_qmesh_curvilinear_edges_contours(self): self.assertEqual(op_contours, contour) def test_image_contours_filled(self): + img = Image(np.array([[0, 2, 0], [0, 2, 0]])) + # Two polygons (NaN-separated) without holes + op_contours = contours(img, filled=True, levels=[0.5, 1.5]) + data = [[(-0.25, -0.25, 1), (-0.08333333, -0.25, 1), (-0.08333333, 0.25, 1), + (-0.25, 0.25, 1), (-0.25, -0.25, 1), (np.nan, np.nan, 1), (0.08333333, -0.25, 1), + (0.25, -0.25, 1), (0.25, 0.25, 1), (0.08333333, 0.25, 1), (0.08333333, -0.25, 1)]] + polys = Polygons(data, vdims=img.vdims[0].clone(range=(0.5, 1.5))) + self.assertEqual(op_contours, polys) + + def test_image_contours_filled_with_hole(self): img = Image(np.array([[0, 0, 0], [0, 1, 0.], [0, 0, 0]])) # Single polygon with hole op_contours = contours(img, filled=True, levels=[0.25, 0.75]) @@ -203,6 +249,22 @@ def test_image_contours_filled(self): [0.08333333, 0.0], [0.0, -0.08333333]])]]] np.testing.assert_array_almost_equal(op_contours.holes(), expected_holes) + def test_image_contours_filled_multi_holes(self): + img = Image(np.array([[0, 0, 0, 0, 0], [0, 1, 0, 1, 0], [0, 0, 0, 0, 0]])) + # Single polygon with two holes + op_contours = contours(img, filled=True, levels=[-0.5, 0.5]) + data = [[(-0.4, -0.3333333, 0), (-0.2, -0.3333333, 0), (0, -0.3333333, 0), + (0.2, -0.3333333, 0), (0.4, -0.3333333, 0), (0.4, 0, 0), (0.4, 0.3333333, 0), + (0.2, 0.3333333, 0), (0, 0.3333333, 0), (-0.2, 0.3333333, 0), (-0.4, 0.3333333, 0), + (-0.4, 0, 0), (-0.4, -0.3333333, 0)]] + polys = Polygons(data, vdims=img.vdims[0].clone(range=(-0.5, 0.5))) + self.assertEqual(op_contours, polys) + expected_holes = [[[np.array([[-0.2, -0.16666667], [-0.3, 0], [-0.2, 0.16666667], [-0.1, 0], + [-0.2, -0.16666667]]), + np.array([[0.2, -0.16666667], [0.1, 0], [0.2, 0.16666667], [0.3, 0], + [0.2, -0.16666667]])]]] + np.testing.assert_array_almost_equal(op_contours.holes(), expected_holes) + def test_image_contours_filled_empty(self): img = Image(np.array([[0, 1, 0], [3, 4, 5.], [6, 7, 8]])) # Contour level outside of data limits @@ -210,14 +272,14 @@ def test_image_contours_filled_empty(self): polys = Polygons(None, vdims=img.vdims[0].clone(range=(20.0, 23.0))) self.assertEqual(op_contours, polys) - def test_image_contours_filled_xy_datetime(self): - x = np.array(['2023-09-01', '2023-09-02', '2023-09-03'], dtype='datetime64') - y = np.array(['2023-10-07', '2023-10-08', '2023-10-09'], dtype='datetime64') - z = np.array([[0, 0, 0], [0, 1, 0.], [0, 0, 0]]) + def test_image_contours_filled_x_datetime(self): + x = np.array(['2023-09-01', '2023-09-05', '2023-09-09'], dtype='datetime64') + y = np.array([6, 7]) + z = np.array([[0, 2, 0], [0, 2, 0]]) img = Image((x, y, z)) msg = 'Datetime spatial coordinates are not supported for filled contour calculations.' with pytest.raises(RuntimeError, match=msg): - _ = contours(img, filled=True, levels=[0.5, 2.0]) + _ = contours(img, filled=True, levels=[0.5, 1.5]) def test_points_histogram(self): points = Points([float(i) for i in range(10)])