From cc35d10887c2433426322356acc2e165d07c4f62 Mon Sep 17 00:00:00 2001 From: Ruth Comer Date: Tue, 11 Jul 2023 14:51:16 +0100 Subject: [PATCH] MNT: update contour for mpl v3.8 --- lib/cartopy/crs.py | 7 +- lib/cartopy/mpl/contour.py | 126 +++++++++++++++---------- lib/cartopy/mpl/geoaxes.py | 33 ++++--- lib/cartopy/tests/mpl/test_examples.py | 5 +- 4 files changed, 103 insertions(+), 68 deletions(-) diff --git a/lib/cartopy/crs.py b/lib/cartopy/crs.py index e138672eb..ab7e2157b 100644 --- a/lib/cartopy/crs.py +++ b/lib/cartopy/crs.py @@ -1247,7 +1247,8 @@ def quick_vertices_transform(self, vertices, src_crs): is required (see :meth:`cartopy.crs.Projection.project_geometry`). """ - return_value = None + if vertices.size == 0: + return vertices if self == src_crs: x = vertices[:, 0] @@ -1258,9 +1259,7 @@ def quick_vertices_transform(self, vertices, src_crs): y_limits = (self.y_limits[0] - epsilon, self.y_limits[1] + epsilon) if (x.min() >= x_limits[0] and x.max() <= x_limits[1] and y.min() >= y_limits[0] and y.max() <= y_limits[1]): - return_value = vertices - - return return_value + return vertices class _RectangularProjection(Projection, metaclass=ABCMeta): diff --git a/lib/cartopy/mpl/contour.py b/lib/cartopy/mpl/contour.py index cf187b79b..47f2f2401 100644 --- a/lib/cartopy/mpl/contour.py +++ b/lib/cartopy/mpl/contour.py @@ -4,10 +4,11 @@ # See COPYING and COPYING.LESSER in the root of the repository for full # licensing details. - +import matplotlib as mpl from matplotlib.contour import QuadContourSet import matplotlib.path as mpath import numpy as np +import packaging class GeoContourSet(QuadContourSet): @@ -20,66 +21,91 @@ class GeoContourSet(QuadContourSet): # fiddling with instance.__class__. def clabel(self, *args, **kwargs): - # nb: contour labelling does not work very well for filled - # contours - it is recommended to only label line contours. - # This is especially true when inline=True. - - # This wrapper exist because mpl does not properly transform - # paths. Instead it simply assumes one path represents one polygon - # (not necessarily the case), and it assumes that - # transform(path.verts) is equivalent to transform_path(path). - # Unfortunately there is no way to easily correct this error, - # so we are forced to pre-transform the ContourSet's paths from - # the source coordinate system to the axes' projection. - # The existing mpl code then has a much simpler job of handling - # pre-projected paths (which can now effectively be transformed - # naively). - - for col in self.collections: - # Snaffle the collection's path list. We will change the - # list in-place (as the contour label code does in mpl). - paths = col.get_paths() + if packaging.version.parse(mpl.__version__).release[:2] < (3, 8): + # nb: contour labelling does not work very well for filled + # contours - it is recommended to only label line contours. + # This is especially true when inline=True. + + # This wrapper exist because mpl does not properly transform + # paths. Instead it simply assumes one path represents one polygon + # (not necessarily the case), and it assumes that + # transform(path.verts) is equivalent to transform_path(path). + # Unfortunately there is no way to easily correct this error, + # so we are forced to pre-transform the ContourSet's paths from + # the source coordinate system to the axes' projection. + # The existing mpl code then has a much simpler job of handling + # pre-projected paths (which can now effectively be transformed + # naively). + + for col in self.collections: + # Snaffle the collection's path list. We will change the + # list in-place (as the contour label code does in mpl). + paths = col.get_paths() + + # Define the transform that will take us from collection + # coordinates through to axes projection coordinates. + data_t = self.axes.transData + col_to_data = col.get_transform() - data_t + + # Now that we have the transform, project all of this + # collection's paths. + new_paths = [col_to_data.transform_path(path) + for path in paths] + new_paths = [path for path in new_paths + if path.vertices.size >= 1] + + # The collection will now be referenced in axes projection + # coordinates. + col.set_transform(data_t) + + # Clear the now incorrectly referenced paths. + del paths[:] + + for path in new_paths: + if path.vertices.size == 0: + # Don't persist empty paths. Let's get rid of them. + continue + + # Split the path if it has multiple MOVETO statements. + codes = np.array( + path.codes if path.codes is not None else [0]) + moveto = codes == mpath.Path.MOVETO + if moveto.sum() <= 1: + # This is only one path, so add it to the collection. + paths.append(path) + else: + # The first MOVETO doesn't need cutting-out. + moveto[0] = False + split_locs = np.flatnonzero(moveto) + + split_verts = np.split(path.vertices, split_locs) + split_codes = np.split(path.codes, split_locs) + + for verts, codes in zip(split_verts, split_codes): + # Add this path to the collection's list of paths. + paths.append(mpath.Path(verts, codes)) + + else: + # Where contour paths exist at the edge of the globe, sometimes a + # complete path in data space will become multiple paths when + # transformed into axes or screen space. Matplotlib's contour + # labelling does not account for this so we need to give it the + # pre-transformed paths to work with. # Define the transform that will take us from collection # coordinates through to axes projection coordinates. data_t = self.axes.transData - col_to_data = col.get_transform() - data_t + col_to_data = self.get_transform() - data_t # Now that we have the transform, project all of this # collection's paths. + paths = self.get_paths() new_paths = [col_to_data.transform_path(path) for path in paths] - new_paths = [path for path in new_paths if path.vertices.size >= 1] + paths[:] = new_paths # The collection will now be referenced in axes projection # coordinates. - col.set_transform(data_t) - - # Clear the now incorrectly referenced paths. - del paths[:] - - for path in new_paths: - if path.vertices.size == 0: - # Don't persist empty paths. Let's get rid of them. - continue - - # Split the path if it has multiple MOVETO statements. - codes = np.array( - path.codes if path.codes is not None else [0]) - moveto = codes == mpath.Path.MOVETO - if moveto.sum() <= 1: - # This is only one path, so add it to the collection. - paths.append(path) - else: - # The first MOVETO doesn't need cutting-out. - moveto[0] = False - split_locs = np.flatnonzero(moveto) - - split_verts = np.split(path.vertices, split_locs) - split_codes = np.split(path.codes, split_locs) - - for verts, codes in zip(split_verts, split_codes): - # Add this path to the collection's list of paths. - paths.append(mpath.Path(verts, codes)) + self.set_transform(data_t) # Now that we have prepared the collection paths, call on # through to the underlying implementation. diff --git a/lib/cartopy/mpl/geoaxes.py b/lib/cartopy/mpl/geoaxes.py index c1d84bf7c..a524182f5 100644 --- a/lib/cartopy/mpl/geoaxes.py +++ b/lib/cartopy/mpl/geoaxes.py @@ -45,7 +45,8 @@ from cartopy.mpl.slippy_image_artist import SlippyImageArtist -assert packaging.version.parse(mpl.__version__).release[:2] >= (3, 4), \ +_MPL_VERSION = packaging.version.parse(mpl.__version__) +assert _MPL_VERSION.release >= (3, 4), \ 'Cartopy is only supported with Matplotlib 3.4 or greater.' # A nested mapping from path, source CRS, and target projection to the @@ -1602,12 +1603,15 @@ def contour(self, *args, **kwargs): result = super().contour(*args, **kwargs) # We need to compute the dataLim correctly for contours. - bboxes = [col.get_datalim(self.transData) - for col in result.collections - if col.get_paths()] - if bboxes: - extent = mtransforms.Bbox.union(bboxes) - self.update_datalim(extent.get_points()) + if _MPL_VERSION.release[:2] < (3, 8): + bboxes = [col.get_datalim(self.transData) + for col in result.collections + if col.get_paths()] + if bboxes: + extent = mtransforms.Bbox.union(bboxes) + self.update_datalim(extent.get_points()) + else: + self.update_datalim(result.get_datalim(self.transData)) self.autoscale_view() @@ -1650,12 +1654,15 @@ def contourf(self, *args, **kwargs): result = super().contourf(*args, **kwargs) # We need to compute the dataLim correctly for contours. - bboxes = [col.get_datalim(self.transData) - for col in result.collections - if col.get_paths()] - if bboxes: - extent = mtransforms.Bbox.union(bboxes) - self.update_datalim(extent.get_points()) + if _MPL_VERSION.release[:2] < (3, 8): + bboxes = [col.get_datalim(self.transData) + for col in result.collections + if col.get_paths()] + if bboxes: + extent = mtransforms.Bbox.union(bboxes) + self.update_datalim(extent.get_points()) + else: + self.update_datalim(result.get_datalim(self.transData)) self.autoscale_view() diff --git a/lib/cartopy/tests/mpl/test_examples.py b/lib/cartopy/tests/mpl/test_examples.py index e2ba220bf..76f76d292 100644 --- a/lib/cartopy/tests/mpl/test_examples.py +++ b/lib/cartopy/tests/mpl/test_examples.py @@ -8,6 +8,7 @@ import pytest import cartopy.crs as ccrs +from cartopy.tests.mpl import MPL_VERSION @pytest.mark.natural_earth @@ -31,7 +32,9 @@ def test_global_map(): @pytest.mark.natural_earth -@pytest.mark.mpl_image_compare(filename='contour_label.png', tolerance=0.5) +@pytest.mark.mpl_image_compare( + filename='contour_label.png', + tolerance=3.9 if MPL_VERSION.release[:2] >= (3, 8) else 0.5) def test_contour_label(): from cartopy.tests.mpl.test_caching import sample_data fig = plt.figure()