From 451df1e079e9e9b579a4d9fddfc65fb7b06c338f Mon Sep 17 00:00:00 2001 From: Jeffrey Gerard Date: Sun, 10 Jan 2016 07:44:45 -0800 Subject: [PATCH 01/15] Implement plotting using matplotlib Collections This doesn't use Collection.set_array() because I can't find a way to share the same colorscale across different collection types. This also adds a legend for noncategorical dataframes. However I deprecated `facecolor` kwarg because it conflicts with `color`. --- geopandas/plotting.py | 288 ++++++++++++++++++++++++++--------------- tests/test_plotting.py | 246 +++++++++++++++-------------------- 2 files changed, 292 insertions(+), 242 deletions(-) diff --git a/geopandas/plotting.py b/geopandas/plotting.py index 38d404e..aaf82da 100644 --- a/geopandas/plotting.py +++ b/geopandas/plotting.py @@ -3,55 +3,113 @@ from __future__ import print_function import warnings import numpy as np +import pandas as pd from six import next from six.moves import xrange -from shapely.geometry import Polygon - -def plot_polygon(ax, poly, facecolor='red', edgecolor='black', alpha=0.5, linewidth=1.0, **kwargs): - """ Plot a single Polygon geometry """ - from descartes.patch import PolygonPatch - a = np.asarray(poly.exterior) - if poly.has_z: - poly = Polygon(zip(*poly.exterior.xy)) - - # without Descartes, we could make a Patch of exterior - ax.add_patch(PolygonPatch(poly, facecolor=facecolor, linewidth=0, alpha=alpha)) # linewidth=0 because boundaries are drawn separately - ax.plot(a[:, 0], a[:, 1], color=edgecolor, linewidth=linewidth, **kwargs) - for p in poly.interiors: - x, y = zip(*p.coords) - ax.plot(x, y, color=edgecolor, linewidth=linewidth) - - -def plot_multipolygon(ax, geom, facecolor='red', edgecolor='black', alpha=0.5, linewidth=1.0, **kwargs): - """ Can safely call with either Polygon or Multipolygon geometry +def _flatten_multi_geoms(geoms, colors): """ - if geom.type == 'Polygon': - plot_polygon(ax, geom, facecolor=facecolor, edgecolor=edgecolor, alpha=alpha, linewidth=linewidth, **kwargs) - elif geom.type == 'MultiPolygon': - for poly in geom.geoms: - plot_polygon(ax, poly, facecolor=facecolor, edgecolor=edgecolor, alpha=alpha, linewidth=linewidth, **kwargs) - - -def plot_linestring(ax, geom, color='black', linewidth=1.0, **kwargs): - """ Plot a single LineString geometry """ - a = np.array(geom) - ax.plot(a[:, 0], a[:, 1], color=color, linewidth=linewidth, **kwargs) - - -def plot_multilinestring(ax, geom, color='red', linewidth=1.0, **kwargs): - """ Can safely call with either LineString or MultiLineString geometry + Returns Series like geoms and colors, except that any Multi geometries + are split into their components and colors are repeated for all component + in the same Multi geometry. Maintains 1:1 matching of geometry to color. """ - if geom.type == 'LineString': - plot_linestring(ax, geom, color=color, linewidth=linewidth, **kwargs) - elif geom.type == 'MultiLineString': - for line in geom.geoms: - plot_linestring(ax, line, color=color, linewidth=linewidth, **kwargs) + components, component_colors = [], [] + assert len(geoms) == len(colors) # precondition, so zip can't short-circuit + for geom, color in zip(geoms, colors): + if geom.type.startswith('Multi'): + for poly in geom: + components.append(poly) + # repeat same color for all components + component_colors.append(color) + else: + components.append(geom) + component_colors.append(color) + return components, component_colors -def plot_point(ax, pt, marker='o', markersize=2, color='black', **kwargs): - """ Plot a single Point geometry """ - ax.plot(pt.x, pt.y, marker=marker, markersize=markersize, color=color, **kwargs) +def plot_polygon_collection(ax, geoms, facecolors, edgecolor='black', + alpha=0.5, linewidth=1.0, **kwargs): + """ + Plots a collection of Polygon and MultiPolygon geometries to `ax` + + Parameters + ---------- + geoms : a sequence of shapely Polygons and/or MultiPolygons (can be mixed) + facecolors : a single color string or sequence of RGBA tuples. + If the sequence, it should have 1:1 correspondence with the geometries + (not their components). If a single string, it is used for all geometries. + + Returns + ------- + patches : matplotlib.collections.Collection + """ + + from matplotlib.collections import PatchCollection + from matplotlib.patches import Polygon + + if isinstance(facecolors, str): + facecolors = [facecolors] * len(geoms) + components, component_colors = _flatten_multi_geoms(geoms, facecolors) + + patches = [Polygon(poly.exterior) for poly in components] + patches = PatchCollection(patches, facecolor=component_colors, + linewidth=linewidth, edgecolor=edgecolor, + alpha=alpha, **kwargs) + # TODO: draw polygon interior(s) + + ax.add_collection(patches, autolim=True) + ax.autoscale_view() + return patches + + +def plot_linestring_collection(ax, geoms, colors, linewidth=1.0, **kwargs): + """ + Plots a collection of LineString and MultiLineString geometries to `ax` + + Parameters + ---------- + geoms : a sequence of shapely LineString and/or MultiLineString (can be mixed) + colors : a single color string or sequence of RGBA tuples. + If the sequence, it should have 1:1 correspondence with the geometries + (not their components). If a single string, it is used for all geometries. + + Returns + ------- + collection : matplotlib.collections.Collection + """ + + from matplotlib.collections import LineCollection + + if isinstance(colors, str): + colors = [colors] * len(geoms) + components, component_colors = _flatten_multi_geoms(geoms, colors) + + segments = [np.array(linestring)[:, :2] for linestring in components] + collection = LineCollection(segments, color=component_colors, + linewidth=linewidth, **kwargs) + + ax.add_collection(collection, autolim=True) + ax.autoscale_view() + return collection + + +def plot_point_collection(ax, geoms, colors, marker='o', markersize=2, **kwargs): + """ + Plots a collection of Point geometries to `ax` + + Parameters + ---------- + geoms : a sequence of Points + colors : a single color string or sequence of RGBA tuples. + + Returns + ------- + collection : matplotlib.collections.Collection + """ + x = [p.x for p in geoms] + y = [p.y for p in geoms] + collection = ax.scatter(x, y, c=colors, marker=marker, s=markersize, **kwargs) + return collection def gencolor(N, colormap='Set1'): @@ -125,26 +183,37 @@ def plot_series(s, cmap='Set1', color=None, ax=None, linewidth=1.0, warnings.warn("'axes' is deprecated, please use 'ax' instead " "(for consistency with pandas)", FutureWarning) ax = color_kwds.pop('axes') + if 'facecolor' in color_kwds: + warnings.warn("'facecolor' is deprecated, please use 'color' instead " + "(for consistency across geometry types)", FutureWarning) + color = color_kwds.pop('facecolor') if not color else color import matplotlib.pyplot as plt if ax is None: fig, ax = plt.subplots(figsize=figsize) ax.set_aspect('equal') - color_generator = gencolor(len(s), colormap=cmap) - for geom in s: - if color is None: - col = next(color_generator) - else: - col = color - if geom.type == 'Polygon' or geom.type == 'MultiPolygon': - if 'facecolor' in color_kwds: - plot_multipolygon(ax, geom, linewidth=linewidth, **color_kwds) - else: - plot_multipolygon(ax, geom, facecolor=col, linewidth=linewidth, **color_kwds) - elif geom.type == 'LineString' or geom.type == 'MultiLineString': - plot_multilinestring(ax, geom, color=col, linewidth=linewidth, **color_kwds) - elif geom.type == 'Point': - plot_point(ax, geom, color=col, **color_kwds) + + if color: + colors = color # single color: all geoms will cycle over it + else: + color_generator = gencolor(len(s), colormap=cmap) + colors = [next(color_generator) for _ in xrange(len(s.index))] + + poly_idx = (s.geometry.type == 'Polygon') | (s.geometry.type == 'MultiPolygon') + polys = s.geometry[poly_idx] + if not polys.empty: + plot_polygon_collection(ax, polys, colors, linewidth=linewidth, **color_kwds) + + line_idx = (s.geometry.type == 'LineString') | (s.geometry.type == 'MultiLineString') + lines = s.geometry[line_idx] + if not lines.empty: + plot_linestring_collection(ax, lines, colors, linewidth=linewidth, **color_kwds) + + point_idx = (s.geometry.type == 'Point') + points = s.geometry[point_idx] + if not points.empty: + plot_point_collection(ax, points, colors, **color_kwds) + plt.draw() return ax @@ -186,8 +255,7 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, Line width for geometries. legend : bool (default False) - Plot a legend (Experimental; currently for categorical - plots only) + Plot a legend. Ignored if no `column` is given, or if `color` is given. ax : matplotlib.pyplot.Artist (default None) axes on which to draw the plot @@ -228,6 +296,10 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, warnings.warn("'axes' is deprecated, please use 'ax' instead " "(for consistency with pandas)", FutureWarning) ax = color_kwds.pop('axes') + if 'facecolor' in color_kwds: + warnings.warn("'facecolor' is deprecated, please use 'color' instead " + "(for consistency across geometry types)", FutureWarning) + color = color_kwds.pop('facecolor') if not color else color import matplotlib.pyplot as plt from matplotlib.lines import Line2D @@ -238,52 +310,63 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, return plot_series(s.geometry, cmap=cmap, color=color, ax=ax, linewidth=linewidth, figsize=figsize, **color_kwds) + + if s[column].dtype is np.dtype('O'): + categorical = True + if categorical: + if cmap is None: + cmap = 'Set1' + categories = list(set(s[column].values)) + categories.sort() + valuemap = dict([(k, v) for (v, k) in enumerate(categories)]) + values = pd.Series([valuemap[k] for k in s[column]]) else: - if s[column].dtype is np.dtype('O'): - categorical = True + values = s[column] + if scheme is not None: + binning = __pysal_choro(values, scheme, k=k) + values = binning.yb # TODO: what type is values? It needs to be a pd.Series... + # set categorical to True for creating the legend + categorical = True + binedges = [binning.yb.min()] + binning.bins.tolist() + categories = ['{0:.2f} - {1:.2f}'.format(binedges[i], binedges[i+1]) + for i in range(len(binedges)-1)] + cmap = norm_cmap(values, cmap, Normalize, cm, vmin=vmin, vmax=vmax) + if ax is None: + fig, ax = plt.subplots(figsize=figsize) + ax.set_aspect('equal') + + # plot all Polygons and all components of MultiPolygon in the same collection + poly_idx = (s.geometry.type == 'Polygon') | (s.geometry.type == 'MultiPolygon') + polys = s.geometry[poly_idx] + if not polys.empty: + colors = color if color else cmap.to_rgba(values[poly_idx]) + plot_polygon_collection(ax, polys, colors, linewidth=linewidth, **color_kwds) + + line_idx = (s.geometry.type == 'LineString') | (s.geometry.type == 'MultiLineString') + lines = s.geometry[line_idx] + if not lines.empty: + colors = color if color else cmap.to_rgba(values[line_idx]) + plot_linestring_collection(ax, lines, colors, linewidth=linewidth, **color_kwds) + + point_idx = (s.geometry.type == 'Point') + points = s.geometry[point_idx] + if not points.empty: + # In order to keep different collections (different geometry types) in sync + # with the same colorbar, we must resolve every geometry's colors globally. + colors = color if color else cmap.to_rgba(values[point_idx]) + plot_point_collection(ax, points, colors, **color_kwds) + + if legend and not color: if categorical: - if cmap is None: - cmap = 'Set1' - categories = list(set(s[column].values)) - categories.sort() - valuemap = dict([(k, v) for (v, k) in enumerate(categories)]) - values = [valuemap[k] for k in s[column]] + patches = [] + for value, cat in enumerate(categories): + patches.append(Line2D([0], [0], linestyle="none", + marker="o", alpha=color_kwds.get('alpha', 0.5), + markersize=10, markerfacecolor=cmap.to_rgba(value))) + ax.legend(patches, categories, numpoints=1, loc='best') else: - values = s[column] - if scheme is not None: - binning = __pysal_choro(values, scheme, k=k) - values = binning.yb - # set categorical to True for creating the legend - categorical = True - binedges = [binning.yb.min()] + binning.bins.tolist() - categories = ['{0:.2f} - {1:.2f}'.format(binedges[i], binedges[i+1]) - for i in range(len(binedges)-1)] - cmap = norm_cmap(values, cmap, Normalize, cm, vmin=vmin, vmax=vmax) - if ax is None: - fig, ax = plt.subplots(figsize=figsize) - ax.set_aspect('equal') - for geom, value in zip(s.geometry, values): - if color is None: - col = cmap.to_rgba(value) - else: - col = color - if geom.type == 'Polygon' or geom.type == 'MultiPolygon': - plot_multipolygon(ax, geom, facecolor=col, linewidth=linewidth, **color_kwds) - elif geom.type == 'LineString' or geom.type == 'MultiLineString': - plot_multilinestring(ax, geom, color=col, linewidth=linewidth, **color_kwds) - elif geom.type == 'Point': - plot_point(ax, geom, color=col, **color_kwds) - if legend: - if categorical: - patches = [] - for value, cat in enumerate(categories): - patches.append(Line2D([0], [0], linestyle="none", - marker="o", alpha=color_kwds.get('alpha', 0.5), - markersize=10, markerfacecolor=cmap.to_rgba(value))) - ax.legend(patches, categories, numpoints=1, loc='best') - else: - # TODO: show a colorbar - raise NotImplementedError + ax.get_figure().colorbar(cmap) + plt.draw() return ax @@ -370,4 +453,5 @@ def norm_cmap(values, cmap, normalize, cm, vmin=None, vmax=None): mx = max(values) if vmax is None else vmax norm = normalize(vmin=mn, vmax=mx) n_cmap = cm.ScalarMappable(norm=norm, cmap=cmap) + n_cmap.set_array([]) return n_cmap diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 0e69003..ea3b839 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, division +import itertools import numpy as np import os import shutil @@ -11,103 +12,14 @@ from matplotlib.pyplot import Artist, savefig, clf, cm, get_cmap from matplotlib.testing.noseclasses import ImageComparisonFailure from matplotlib.testing.compare import compare_images from numpy import cos, sin, pi -from shapely.geometry import Polygon, LineString, Point +from shapely.affinity import rotate +from shapely.geometry import MultiPolygon, Polygon, LineString, Point from six.moves import xrange from .util import unittest from geopandas import GeoSeries, GeoDataFrame, read_file -# If set to True, generate images rather than perform tests (all tests will pass!) -GENERATE_BASELINE = False - -BASELINE_DIR = os.path.join(os.path.dirname(__file__), 'baseline_images', 'test_plotting') - -TRAVIS = bool(os.environ.get('TRAVIS', False)) - - -class TestImageComparisons(unittest.TestCase): - - def setUp(self): - self.tempdir = tempfile.mkdtemp() - return - - def tearDown(self): - shutil.rmtree(self.tempdir) - return - - def _compare_images(self, ax, filename, tol=10): - """ Helper method to do the comparisons """ - assert isinstance(ax, Artist) - if GENERATE_BASELINE: - savefig(os.path.join(BASELINE_DIR, filename)) - savefig(os.path.join(self.tempdir, filename)) - err = compare_images(os.path.join(BASELINE_DIR, filename), - os.path.join(self.tempdir, filename), - tol, in_decorator=True) - if err: - raise ImageComparisonFailure('images not close: %(actual)s ' - 'vs. %(expected)s ' - '(RMS %(rms).3f)' % err) - - def test_poly_plot(self): - """ Test plotting a simple series of polygons """ - clf() - filename = 'poly_plot.png' - t1 = Polygon([(0, 0), (1, 0), (1, 1)]) - t2 = Polygon([(1, 0), (2, 0), (2, 1)]) - polys = GeoSeries([t1, t2]) - ax = polys.plot() - self._compare_images(ax=ax, filename=filename) - - def test_point_plot(self): - """ Test plotting a simple series of points """ - clf() - filename = 'points_plot.png' - N = 10 - points = GeoSeries(Point(i, i) for i in xrange(N)) - ax = points.plot() - self._compare_images(ax=ax, filename=filename) - - def test_line_plot(self): - """ Test plotting a simple series of lines """ - clf() - filename = 'lines_plot.png' - N = 10 - lines = GeoSeries([LineString([(0, i), (9, i)]) for i in xrange(N)]) - ax = lines.plot() - self._compare_images(ax=ax, filename=filename) - - @unittest.skipIf(TRAVIS, 'Skip on Travis (fails even though it passes locally)') - def test_plot_GeoDataFrame_with_kwargs(self): - """ - Test plotting a simple GeoDataFrame consisting of a series of polygons - with increasing values using various extra kwargs. - """ - clf() - filename = 'poly_plot_with_kwargs.png' - ts = np.linspace(0, 2*pi, 10, endpoint=False) - - # Build GeoDataFrame from a series of triangles wrapping around in a ring - # and a second column containing a list of increasing values. - r1 = 1.0 # radius of inner ring boundary - r2 = 1.5 # radius of outer ring boundary - - def make_triangle(t0, t1): - return Polygon([(r1*cos(t0), r1*sin(t0)), - (r2*cos(t0), r2*sin(t0)), - (r1*cos(t1), r1*sin(t1))]) - - polys = GeoSeries([make_triangle(t0, t1) for t0, t1 in zip(ts, ts[1:])]) - values = np.arange(len(polys)) - df = GeoDataFrame({'geometry': polys, 'values': values}) - - # Plot the GeoDataFrame using various keyword arguments to see if they are honoured - ax = df.plot(column='values', cmap=cm.RdBu, vmin=+2, vmax=None, figsize=(8, 4)) - self._compare_images(ax=ax, filename=filename) - - - class TestPointPlotting(unittest.TestCase): def setUp(self): @@ -125,21 +37,19 @@ class TestPointPlotting(unittest.TestCase): ax = self.points.plot() cmap = get_cmap('Set1', 9) expected_colors = cmap(list(range(9))*2) - _check_colors(ax.get_lines(), expected_colors) + _check_colors(self.N, ax.collections[0], expected_colors) # GeoDataFrame -> uses 'jet' instead of 'Set1' ax = self.df.plot() cmap = get_cmap('jet', 9) expected_colors = cmap(list(range(9))*2) - _check_colors(ax.get_lines(), expected_colors) - - ## with specifying values + _check_colors(self.N, ax.collections[0], expected_colors) + ## with specifying values -> different colors for all 10 values ax = self.df.plot(column='values') cmap = get_cmap('jet') expected_colors = cmap(np.arange(self.N)/(self.N-1)) - - _check_colors(ax.get_lines(), expected_colors) + _check_colors(self.N, ax.collections[0], expected_colors) def test_colormap(self): @@ -149,44 +59,59 @@ class TestPointPlotting(unittest.TestCase): ax = self.points.plot(cmap='RdYlGn') cmap = get_cmap('RdYlGn', 9) expected_colors = cmap(list(range(9))*2) - _check_colors(ax.get_lines(), expected_colors) + _check_colors(self.N, ax.collections[0], expected_colors) # GeoDataFrame -> same as GeoSeries in this case ax = self.df.plot(cmap='RdYlGn') - _check_colors(ax.get_lines(), expected_colors) - - ## with specifying values + _check_colors(self.N, ax.collections[0], expected_colors) + ## with specifying values -> different colors for all 10 values ax = self.df.plot(column='values', cmap='RdYlGn') cmap = get_cmap('RdYlGn') expected_colors = cmap(np.arange(self.N)/(self.N-1)) - _check_colors(ax.get_lines(), expected_colors) + _check_colors(self.N, ax.collections[0], expected_colors) def test_single_color(self): ax = self.points.plot(color='green') - _check_colors(ax.get_lines(), ['green']*self.N) + _check_colors(self.N, ax.collections[0], ['green']*self.N) ax = self.df.plot(color='green') - _check_colors(ax.get_lines(), ['green']*self.N) + _check_colors(self.N, ax.collections[0], ['green']*self.N) ax = self.df.plot(column='values', color='green') - _check_colors(ax.get_lines(), ['green']*self.N) + _check_colors(self.N, ax.collections[0], ['green']*self.N) def test_style_kwargs(self): # markersize ax = self.points.plot(markersize=10) - ms = [l.get_markersize() for l in ax.get_lines()] - assert ms == [10] * self.N + assert ax.collections[0].get_sizes() == [10] ax = self.df.plot(markersize=10) - ms = [l.get_markersize() for l in ax.get_lines()] - assert ms == [10] * self.N + assert ax.collections[0].get_sizes() == [10] ax = self.df.plot(column='values', markersize=10) - ms = [l.get_markersize() for l in ax.get_lines()] - assert ms == [10] * self.N + assert ax.collections[0].get_sizes() == [10] + + def test_legend(self): + # legend ignored if color is given. + ax = self.df.plot(column='values', color='green', legend=True) + assert len(ax.get_figure().axes) == 1 # only the plot, no axis w/ legend + + # legend ignored if no column is given. + ax = self.df.plot(legend=True) + assert len(ax.get_figure().axes) == 1 # only the plot, no axis w/ legend + + # Continuous legend + ## the colorbar matches the Point colors + ax = self.df.plot(column='values', cmap='RdYlGn', legend=True) + point_colors = ax.collections[0].get_facecolors() + cbar_colors = ax.get_figure().axes[1].collections[0].get_facecolors() + ### first point == bottom of colorbar + np.testing.assert_array_equal(point_colors[0], cbar_colors[0]) + ### last point == top of colorbar + np.testing.assert_array_equal(point_colors[-1], cbar_colors[-1]) class TestLineStringPlotting(unittest.TestCase): @@ -201,28 +126,38 @@ class TestLineStringPlotting(unittest.TestCase): def test_single_color(self): ax = self.lines.plot(color='green') - _check_colors(ax.get_lines(), ['green']*self.N) + _check_colors(self.N, ax.collections[0], ['green']*self.N) ax = self.df.plot(color='green') - _check_colors(ax.get_lines(), ['green']*self.N) + _check_colors(self.N, ax.collections[0], ['green']*self.N) ax = self.df.plot(column='values', color='green') - _check_colors(ax.get_lines(), ['green']*self.N) + _check_colors(self.N, ax.collections[0], ['green']*self.N) def test_style_kwargs(self): + def linestyle_tuple_to_string(tup): + """ Converts a linestyle of the form `(offset, onoffseq)`, as + documented in `Collections.set_linestyle`, to a string + representation, namely one of: + { 'dashed', 'dotted', 'dashdot', 'solid' }. + """ + from matplotlib.backend_bases import GraphicsContextBase + reverse_idx = {v:k for k, v in GraphicsContextBase.dashd.iteritems()} + return reverse_idx[tup] + # linestyle ax = self.lines.plot(linestyle='dashed') - ls = [l.get_linestyle() for l in ax.get_lines()] - assert ls == ['--'] * self.N + ls = [linestyle_tuple_to_string(l) for l in ax.collections[0].get_linestyles()] + assert ls == ['dashed'] ax = self.df.plot(linestyle='dashed') - ls = [l.get_linestyle() for l in ax.get_lines()] - assert ls == ['--'] * self.N + ls = [linestyle_tuple_to_string(l) for l in ax.collections[0].get_linestyles()] + assert ls == ['dashed'] ax = self.df.plot(column='values', linestyle='dashed') - ls = [l.get_linestyle() for l in ax.get_lines()] - assert ls == ['--'] * self.N + ls = [linestyle_tuple_to_string(l) for l in ax.collections[0].get_linestyles()] + assert ls == ['dashed'] class TestPolygonPlotting(unittest.TestCase): @@ -233,34 +168,49 @@ class TestPolygonPlotting(unittest.TestCase): t2 = Polygon([(1, 0), (2, 0), (2, 1)]) self.polys = GeoSeries([t1, t2]) self.df = GeoDataFrame({'geometry': self.polys, 'values': [0, 1]}) + + multipoly1 = MultiPolygon([t1, t2]) + multipoly2 = rotate(multipoly1, 180) + self.df2 = GeoDataFrame({'geometry': [multipoly1, multipoly2], + 'values': [0, 1]}) return def test_single_color(self): ax = self.polys.plot(color='green') - _check_colors(ax.patches, ['green']*2, alpha=0.5) + _check_colors(2, ax.collections[0], ['green']*2, alpha=0.5) ax = self.df.plot(color='green') - _check_colors(ax.patches, ['green']*2, alpha=0.5) + _check_colors(2, ax.collections[0], ['green']*2, alpha=0.5) ax = self.df.plot(column='values', color='green') - _check_colors(ax.patches, ['green']*2, alpha=0.5) + _check_colors(2, ax.collections[0], ['green']*2, alpha=0.5) def test_vmin_vmax(self): # when vmin == vmax, all polygons should be the same color ax = self.df.plot(column='values', categorical=True, vmin=0, vmax=0) cmap = get_cmap('Set1', 2) - self.assertEqual(ax.patches[0].get_facecolor(), ax.patches[1].get_facecolor()) + _check_colors(2, ax.collections[0], cmap([0, 0]), alpha=0.5) - def test_facecolor(self): - t1 = Polygon([(0, 0), (1, 0), (1, 1)]) - t2 = Polygon([(1, 0), (2, 0), (2, 1)]) - polys = GeoSeries([t1, t2]) - df = GeoDataFrame({'geometry': polys, 'values': [0, 1]}) + def test_style_kwargs(self): - ax = polys.plot(facecolor='k') - _check_colors(ax.patches, ['k']*2, alpha=0.5) + ax = self.polys.plot(facecolor='k') + _check_colors(2, ax.collections[0], ['k']*2, alpha=0.5) + + def test_multipolygons(self): + + # MultiPolygons + ax = self.df2.plot() + assert len(ax.collections[0].get_paths()) == 4 + cmap = get_cmap('jet', 2) + ## colors are repeated for all components within a MultiPolygon + expected_colors = [cmap(0), cmap(0), cmap(1), cmap(1)] + _check_colors(4, ax.collections[0], expected_colors, alpha=0.5) + + ax = self.df2.plot('values') + ## specifying values -> same as without values in this case. + _check_colors(4, ax.collections[0], expected_colors, alpha=0.5) class TestPySALPlotting(unittest.TestCase): @@ -284,20 +234,36 @@ class TestPySALPlotting(unittest.TestCase): self.assertEqual(labels, expected) -def _check_colors(collection, expected_colors, alpha=None): +def _check_colors(N, collection, expected_colors, alpha=None): + """ Asserts that the members of `collection` match the `expected_colors` (in order) + Parameters + ---------- + N : the number of geometries believed to be in collection. + matplotlib.collection is implemented such that the number of geoms in + `collection` doesn't have to match the number of colors assignments in + the collection: the colors will cycle to meet the needs of the geoms. + `N` helps us resolve this. + collection : matplotlib.collections.Collection + The colors of this collection's patches are read from `collection.get_facecolors()` + expected_colors : sequence of RGBA tuples + alpha : float (optional) + If set, this alpha transparency will be applied to the `expected_colors`. + (Any transparency on the `collecton` is assumed to be set in its own + facecolor RGBA tuples.) + """ from matplotlib.lines import Line2D import matplotlib.colors as colors conv = colors.colorConverter - for patch, color in zip(collection, expected_colors): - if isinstance(patch, Line2D): - # points/lines - result = patch.get_color() - else: - # polygons - result = patch.get_facecolor() - assert conv.to_rgba(result) == conv.to_rgba(color, alpha=alpha) + # Convert 2D numpy array to a list of RGBA tuples. + actual_colors = list(collection.get_facecolors()) + actual_colors = map(tuple, actual_colors) + all_actual_colors = list(itertools.islice(itertools.cycle(actual_colors), N)) + + for actual, expected in zip(all_actual_colors, expected_colors): + assert actual == conv.to_rgba(expected, alpha=alpha), \ + '{} != {}'.format(actual, conv.to_rgba(expected, alpha=alpha)) if __name__ == '__main__': From a41d948cf6f82e07e70f8f3095f881662fcf7559 Mon Sep 17 00:00:00 2001 From: Jeffrey Gerard Date: Sun, 10 Jan 2016 11:11:57 -0800 Subject: [PATCH 02/15] Compatibility for python2.6 and 3 --- geopandas/plotting.py | 2 +- tests/test_plotting.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/geopandas/plotting.py b/geopandas/plotting.py index aaf82da..2ec8082 100644 --- a/geopandas/plotting.py +++ b/geopandas/plotting.py @@ -324,7 +324,7 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, values = s[column] if scheme is not None: binning = __pysal_choro(values, scheme, k=k) - values = binning.yb # TODO: what type is values? It needs to be a pd.Series... + values = pd.Series(binning.yb) # set categorical to True for creating the legend categorical = True binedges = [binning.yb.min()] + binning.bins.tolist() diff --git a/tests/test_plotting.py b/tests/test_plotting.py index ea3b839..3d7fbfa 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -143,7 +143,7 @@ class TestLineStringPlotting(unittest.TestCase): { 'dashed', 'dotted', 'dashdot', 'solid' }. """ from matplotlib.backend_bases import GraphicsContextBase - reverse_idx = {v:k for k, v in GraphicsContextBase.dashd.iteritems()} + reverse_idx = dict((v, k) for k, v in GraphicsContextBase.dashd.items()) return reverse_idx[tup] # linestyle From 734f0b33c685c24d3b27b526e15baf95fe896e25 Mon Sep 17 00:00:00 2001 From: Jeffrey Gerard Date: Sun, 10 Jan 2016 12:06:58 -0800 Subject: [PATCH 03/15] Fix bug where GeoSeries with nonuniform Geometry couldn't share colors --- geopandas/plotting.py | 48 +++++++++++++++++++----------------------- requirements.test.txt | 2 +- tests/test_plotting.py | 23 +++++++++++++++++++- 3 files changed, 45 insertions(+), 28 deletions(-) diff --git a/geopandas/plotting.py b/geopandas/plotting.py index 2ec8082..3521701 100644 --- a/geopandas/plotting.py +++ b/geopandas/plotting.py @@ -35,9 +35,8 @@ def plot_polygon_collection(ax, geoms, facecolors, edgecolor='black', Parameters ---------- geoms : a sequence of shapely Polygons and/or MultiPolygons (can be mixed) - facecolors : a single color string or sequence of RGBA tuples. - If the sequence, it should have 1:1 correspondence with the geometries - (not their components). If a single string, it is used for all geometries. + facecolors : a sequence of RGBA tuples. + It should have 1:1 correspondence with the geometries (not their components). Returns ------- @@ -47,12 +46,10 @@ def plot_polygon_collection(ax, geoms, facecolors, edgecolor='black', from matplotlib.collections import PatchCollection from matplotlib.patches import Polygon - if isinstance(facecolors, str): - facecolors = [facecolors] * len(geoms) components, component_colors = _flatten_multi_geoms(geoms, facecolors) patches = [Polygon(poly.exterior) for poly in components] - patches = PatchCollection(patches, facecolor=component_colors, + patches = PatchCollection(patches, facecolors=component_colors, linewidth=linewidth, edgecolor=edgecolor, alpha=alpha, **kwargs) # TODO: draw polygon interior(s) @@ -69,9 +66,8 @@ def plot_linestring_collection(ax, geoms, colors, linewidth=1.0, **kwargs): Parameters ---------- geoms : a sequence of shapely LineString and/or MultiLineString (can be mixed) - colors : a single color string or sequence of RGBA tuples. - If the sequence, it should have 1:1 correspondence with the geometries - (not their components). If a single string, it is used for all geometries. + colors : a sequence of RGBA tuples. + It should have 1:1 correspondence with the geometries (not their components). Returns ------- @@ -80,8 +76,6 @@ def plot_linestring_collection(ax, geoms, colors, linewidth=1.0, **kwargs): from matplotlib.collections import LineCollection - if isinstance(colors, str): - colors = [colors] * len(geoms) components, component_colors = _flatten_multi_geoms(geoms, colors) segments = [np.array(linestring)[:, :2] for linestring in components] @@ -193,26 +187,27 @@ def plot_series(s, cmap='Set1', color=None, ax=None, linewidth=1.0, fig, ax = plt.subplots(figsize=figsize) ax.set_aspect('equal') + num_geoms = len(s.index) if color: - colors = color # single color: all geoms will cycle over it + colors = pd.Series([color] * num_geoms) else: color_generator = gencolor(len(s), colormap=cmap) - colors = [next(color_generator) for _ in xrange(len(s.index))] + colors = pd.Series([next(color_generator) for _ in xrange(num_geoms)]) poly_idx = (s.geometry.type == 'Polygon') | (s.geometry.type == 'MultiPolygon') polys = s.geometry[poly_idx] if not polys.empty: - plot_polygon_collection(ax, polys, colors, linewidth=linewidth, **color_kwds) + plot_polygon_collection(ax, polys, colors[poly_idx], linewidth=linewidth, **color_kwds) line_idx = (s.geometry.type == 'LineString') | (s.geometry.type == 'MultiLineString') lines = s.geometry[line_idx] if not lines.empty: - plot_linestring_collection(ax, lines, colors, linewidth=linewidth, **color_kwds) + plot_linestring_collection(ax, lines, colors[line_idx], linewidth=linewidth, **color_kwds) point_idx = (s.geometry.type == 'Point') points = s.geometry[point_idx] if not points.empty: - plot_point_collection(ax, points, colors, **color_kwds) + plot_point_collection(ax, points, colors[point_idx], **color_kwds) plt.draw() return ax @@ -319,12 +314,12 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, categories = list(set(s[column].values)) categories.sort() valuemap = dict([(k, v) for (v, k) in enumerate(categories)]) - values = pd.Series([valuemap[k] for k in s[column]]) + values = [valuemap[k] for k in s[column]] else: values = s[column] if scheme is not None: binning = __pysal_choro(values, scheme, k=k) - values = pd.Series(binning.yb) + values = binning.yb # set categorical to True for creating the legend categorical = True binedges = [binning.yb.min()] + binning.bins.tolist() @@ -335,26 +330,27 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, fig, ax = plt.subplots(figsize=figsize) ax.set_aspect('equal') + num_geoms = len(s.index) + if color: + colors = pd.Series([color] * num_geoms) + else: + colors = pd.Series([cmap.to_rgba(v) for v in values]) + # plot all Polygons and all components of MultiPolygon in the same collection poly_idx = (s.geometry.type == 'Polygon') | (s.geometry.type == 'MultiPolygon') polys = s.geometry[poly_idx] if not polys.empty: - colors = color if color else cmap.to_rgba(values[poly_idx]) - plot_polygon_collection(ax, polys, colors, linewidth=linewidth, **color_kwds) + plot_polygon_collection(ax, polys, colors[poly_idx], linewidth=linewidth, **color_kwds) line_idx = (s.geometry.type == 'LineString') | (s.geometry.type == 'MultiLineString') lines = s.geometry[line_idx] if not lines.empty: - colors = color if color else cmap.to_rgba(values[line_idx]) - plot_linestring_collection(ax, lines, colors, linewidth=linewidth, **color_kwds) + plot_linestring_collection(ax, lines, colors[line_idx], linewidth=linewidth, **color_kwds) point_idx = (s.geometry.type == 'Point') points = s.geometry[point_idx] if not points.empty: - # In order to keep different collections (different geometry types) in sync - # with the same colorbar, we must resolve every geometry's colors globally. - colors = color if color else cmap.to_rgba(values[point_idx]) - plot_point_collection(ax, points, colors, **color_kwds) + plot_point_collection(ax, points, colors[point_idx], **color_kwds) if legend and not color: if categorical: diff --git a/requirements.test.txt b/requirements.test.txt index 02991bd..7c18caa 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -1,7 +1,7 @@ psycopg2>=2.5.1 SQLAlchemy>=0.8.3 geopy==1.10.0 -matplotlib>=1.2.1 +matplotlib>=1.5.0 descartes>=1.0 mock>=1.0.1 # technically not need for python >= 3.3 pytest-cov diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 3d7fbfa..1fe2783 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -120,7 +120,8 @@ class TestLineStringPlotting(unittest.TestCase): self.N = 10 values = np.arange(self.N) - self.lines = GeoSeries([LineString([(0, i), (9, i)]) for i in xrange(self.N)]) + self.lines = GeoSeries([LineString([(0, i), (4, i+0.5), (9, i)]) + for i in xrange(self.N)]) self.df = GeoDataFrame({'geometry': self.lines, 'values': values}) def test_single_color(self): @@ -213,6 +214,26 @@ class TestPolygonPlotting(unittest.TestCase): _check_colors(4, ax.collections[0], expected_colors, alpha=0.5) +class TestNonuniformGeometryPlotting(unittest.TestCase): + + def setUp(self): + + poly = Polygon([(1, 0), (2, 0), (2, 1)]) + line = LineString([(0.5, 0.5), (1, 1), (1, 0.5), (1.5, 1)]) + point = Point(0.75, 0.25) + self.series = GeoSeries([poly, line, point]) + self.df = GeoDataFrame({'geometry': self.series, 'values': [1, 2, 3]}) + return + + def test_colormap(self): + + ax = self.series.plot(cmap='RdYlGn') + cmap = get_cmap('RdYlGn', 3) + _check_colors(1, ax.collections[0], [cmap(0)], alpha=0.5) # polygon gets extra alpha. See #266 + _check_colors(1, ax.collections[1], [cmap(1)], alpha=1) # line + _check_colors(1, ax.collections[2], [cmap(2)], alpha=1) # point + + class TestPySALPlotting(unittest.TestCase): @classmethod From ab443468d69138379a889b1eae0423c22a342a89 Mon Sep 17 00:00:00 2001 From: Jeffrey Gerard Date: Sun, 10 Jan 2016 18:47:32 -0800 Subject: [PATCH 04/15] Fix markersize, which is only relevant to Points. --- geopandas/plotting.py | 6 ++++++ tests/test_plotting.py | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/geopandas/plotting.py b/geopandas/plotting.py index 3521701..cc4258c 100644 --- a/geopandas/plotting.py +++ b/geopandas/plotting.py @@ -48,6 +48,9 @@ def plot_polygon_collection(ax, geoms, facecolors, edgecolor='black', components, component_colors = _flatten_multi_geoms(geoms, facecolors) + # PatchCollection does not accept some kwargs. + if 'markersize' in kwargs: + del kwargs['markersize'] patches = [Polygon(poly.exterior) for poly in components] patches = PatchCollection(patches, facecolors=component_colors, linewidth=linewidth, edgecolor=edgecolor, @@ -78,6 +81,9 @@ def plot_linestring_collection(ax, geoms, colors, linewidth=1.0, **kwargs): components, component_colors = _flatten_multi_geoms(geoms, colors) + # LineCollection does not accept some kwargs. + if 'markersize' in kwargs: + del kwargs['markersize'] segments = [np.array(linestring)[:, :2] for linestring in components] collection = LineCollection(segments, color=component_colors, linewidth=linewidth, **kwargs) diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 1fe2783..6ae203d 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -234,6 +234,16 @@ class TestNonuniformGeometryPlotting(unittest.TestCase): _check_colors(1, ax.collections[2], [cmap(2)], alpha=1) # point + def test_style_kwargs(self): + + # markersize -> only the Point gets it + ax = self.series.plot(markersize=10) + assert ax.collections[2].get_sizes() == [10] + + ax = self.df.plot(markersize=10) + assert ax.collections[2].get_sizes() == [10] + + class TestPySALPlotting(unittest.TestCase): @classmethod From 285c7395ba053aaca32a6634ef9603ab70e378a2 Mon Sep 17 00:00:00 2001 From: Jeffrey Gerard Date: Sun, 10 Jan 2016 22:55:04 -0600 Subject: [PATCH 05/15] Add test for figsize. --- tests/test_plotting.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 6ae203d..e6073e5 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -29,6 +29,14 @@ class TestPointPlotting(unittest.TestCase): values = np.arange(self.N) self.df = GeoDataFrame({'geometry': self.points, 'values': values}) + def test_figsize(self): + + ax = self.points.plot(figsize=(1, 1)) + np.testing.assert_array_equal(ax.figure.get_size_inches(), (1, 1)) + + ax = self.df.plot(figsize=(1, 1)) + np.testing.assert_array_equal(ax.figure.get_size_inches(), (1, 1)) + def test_default_colors(self): ## without specifying values -> max 9 different colors From 17313dc92ae4b3e333bc08600d71585d8fd1c369 Mon Sep 17 00:00:00 2001 From: Jeffrey Gerard Date: Tue, 8 Mar 2016 16:28:16 +0100 Subject: [PATCH 06/15] Use vmin & vmax to standardize color scales across heterogeneous geometry types I added a warning for the case when both column and color are specified, because they conflict. Maintains past behavior that 'color' overrides 'column'. Also: replace descartes (wrongly removed in a previous commit) --- geopandas/plotting.py | 262 ++++++++++++++++++++++++----------------- tests/test_plotting.py | 59 ++++++++-- 2 files changed, 201 insertions(+), 120 deletions(-) diff --git a/geopandas/plotting.py b/geopandas/plotting.py index cc4258c..8179316 100644 --- a/geopandas/plotting.py +++ b/geopandas/plotting.py @@ -7,11 +7,21 @@ import pandas as pd from six import next from six.moves import xrange + def _flatten_multi_geoms(geoms, colors): """ Returns Series like geoms and colors, except that any Multi geometries are split into their components and colors are repeated for all component in the same Multi geometry. Maintains 1:1 matching of geometry to color. + + "Colors" are treated opaquely and so can actually contain any values. + + Returns + ------- + + components : list of geometry + + component_colors : list of whatever type `colors` contains """ components, component_colors = [], [] assert len(geoms) == len(colors) # precondition, so zip can't short-circuit @@ -27,88 +37,165 @@ def _flatten_multi_geoms(geoms, colors): return components, component_colors -def plot_polygon_collection(ax, geoms, facecolors, edgecolor='black', - alpha=0.5, linewidth=1.0, **kwargs): +def plot_polygon_collection(ax, geoms, colors_or_values, plot_values, + vmin=None, vmax=None, cmap=None, + edgecolor='black', alpha=0.5, linewidth=1.0, **kwargs): """ Plots a collection of Polygon and MultiPolygon geometries to `ax` Parameters ---------- - geoms : a sequence of shapely Polygons and/or MultiPolygons (can be mixed) - facecolors : a sequence of RGBA tuples. + + ax : matplotlib.axes.Axes + where shapes will be plotted + + geoms : a sequence of `N` Polygons and/or MultiPolygons (can be mixed) + + colors_or_values : a sequence of `N` values or RGBA tuples It should have 1:1 correspondence with the geometries (not their components). + plot_values : bool + If True, `colors_or_values` is interpreted as a list of values, and will + be mapped to colors using vmin/vmax/cmap (which become required). + Otherwise `colors_or_values` is interpreted as a list of colors. + Returns ------- - patches : matplotlib.collections.Collection + + collection : matplotlib.collections.Collection that was plotted """ + from descartes.patch import PolygonPatch from matplotlib.collections import PatchCollection - from matplotlib.patches import Polygon - components, component_colors = _flatten_multi_geoms(geoms, facecolors) + components, component_colors_or_values = _flatten_multi_geoms(geoms, colors_or_values) # PatchCollection does not accept some kwargs. if 'markersize' in kwargs: del kwargs['markersize'] - patches = [Polygon(poly.exterior) for poly in components] - patches = PatchCollection(patches, facecolors=component_colors, - linewidth=linewidth, edgecolor=edgecolor, - alpha=alpha, **kwargs) - # TODO: draw polygon interior(s) + collection = PatchCollection([PolygonPatch(poly) for poly in components], + linewidth=linewidth, edgecolor=edgecolor, + alpha=alpha, **kwargs) + # TODO: draw polygon interior(s) in the right color. + # Better question: what is the old code on master trying to do, + # adding the Descartes Patch and then separately drawing the boundaries? + # Answer: linewidth=0 because boundaries are drawn separately - ax.add_collection(patches, autolim=True) - ax.autoscale_view() - return patches + if plot_values: + collection.set_array(np.array(component_colors_or_values)) + collection.set_cmap(cmap) + collection.set_clim(vmin, vmax) + else: + # set_color magically sets the correct combination of facecolor and + # edgecolor, based on collection type. + collection.set_color(component_colors_or_values) - -def plot_linestring_collection(ax, geoms, colors, linewidth=1.0, **kwargs): - """ - Plots a collection of LineString and MultiLineString geometries to `ax` - - Parameters - ---------- - geoms : a sequence of shapely LineString and/or MultiLineString (can be mixed) - colors : a sequence of RGBA tuples. - It should have 1:1 correspondence with the geometries (not their components). - - Returns - ------- - collection : matplotlib.collections.Collection - """ - - from matplotlib.collections import LineCollection - - components, component_colors = _flatten_multi_geoms(geoms, colors) - - # LineCollection does not accept some kwargs. - if 'markersize' in kwargs: - del kwargs['markersize'] - segments = [np.array(linestring)[:, :2] for linestring in components] - collection = LineCollection(segments, color=component_colors, - linewidth=linewidth, **kwargs) + # If the user set facecolor and/or edgecolor explicitly, the previous + # call to set_color might have overridden it (remember, the 'color' may + # have come from plot_series, not from the user). The user should be + # able to override matplotlib's default behavior, by setting them again + # after set_color. + if 'facecolor' in kwargs: + collection.set_facecolor(kwargs['facecolor']) + if edgecolor: + collection.set_edgecolor(edgecolor) ax.add_collection(collection, autolim=True) ax.autoscale_view() return collection -def plot_point_collection(ax, geoms, colors, marker='o', markersize=2, **kwargs): +def plot_linestring_collection(ax, geoms, colors_or_values, plot_values, + vmin=None, vmax=None, cmap=None, + linewidth=1.0, **kwargs): + """ + Plots a collection of LineString and MultiLineString geometries to `ax` + + Parameters + ---------- + + ax : matplotlib.axes.Axes + where shapes will be plotted + + geoms : a sequence of `N` LineStrings and/or MultiLineStrings (can be mixed) + + colors_or_values : a sequence of `N` values or RGBA tuples + It should have 1:1 correspondence with the geometries (not their components). + + plot_values : bool + If True, `colors_or_values` is interpreted as a list of values, and will + be mapped to colors using vmin/vmax/cmap (which become required). + Otherwise `colors_or_values` is interpreted as a list of colors. + + Returns + ------- + + collection : matplotlib.collections.Collection that was plotted + """ + + from matplotlib.collections import LineCollection + + components, component_colors_or_values = _flatten_multi_geoms(geoms, colors_or_values) + + # LineCollection does not accept some kwargs. + if 'markersize' in kwargs: + del kwargs['markersize'] + segments = [np.array(linestring)[:, :2] for linestring in components] + collection = LineCollection(segments, + linewidth=linewidth, **kwargs) + + if plot_values: + collection.set_array(np.array(component_colors_or_values)) + collection.set_cmap(cmap) + collection.set_clim(vmin, vmax) + else: + # set_color magically sets the correct combination of facecolor and + # edgecolor, based on collection type. + collection.set_color(component_colors_or_values) + + # If the user set facecolor and/or edgecolor explicitly, the previous + # call to set_color might have overridden it (remember, the 'color' may + # have come from plot_series, not from the user). The user should be + # able to override matplotlib's default behavior, by setting them again + # after set_color. + if 'facecolor' in kwargs: + collection.set_facecolor(kwargs['facecolor']) + + if 'edgecolor' in kwargs: + collection.set_edgecolor(kwargs['edgecolor']) + + ax.add_collection(collection, autolim=True) + ax.autoscale_view() + return collection + + +def plot_point_collection(ax, geoms, colors_or_values, + vmin=None, vmax=None, cmap=None, + marker='o', markersize=2, **kwargs): """ Plots a collection of Point geometries to `ax` Parameters ---------- - geoms : a sequence of Points - colors : a single color string or sequence of RGBA tuples. + + ax : matplotlib.axes.Axes + where shapes will be plotted + + geoms : sequence of `N` Points + + colors_or_values : sequence of color or sequence of numbers + can be a sequence of color specifications of length `N` or a sequence + of `N` numbers to be mapped to colors using vmin, vmax, and cmap. Returns ------- - collection : matplotlib.collections.Collection + collection : matplotlib.collections.Collection that was plotted """ x = [p.x for p in geoms] y = [p.y for p in geoms] - collection = ax.scatter(x, y, c=colors, marker=marker, s=markersize, **kwargs) + collection = ax.scatter(x, y, c=colors_or_values, + vmin=vmin, vmax=vmax, cmap=cmap, + marker=marker, s=markersize, **kwargs) return collection @@ -183,10 +270,6 @@ def plot_series(s, cmap='Set1', color=None, ax=None, linewidth=1.0, warnings.warn("'axes' is deprecated, please use 'ax' instead " "(for consistency with pandas)", FutureWarning) ax = color_kwds.pop('axes') - if 'facecolor' in color_kwds: - warnings.warn("'facecolor' is deprecated, please use 'color' instead " - "(for consistency across geometry types)", FutureWarning) - color = color_kwds.pop('facecolor') if not color else color import matplotlib.pyplot as plt if ax is None: @@ -203,12 +286,12 @@ def plot_series(s, cmap='Set1', color=None, ax=None, linewidth=1.0, poly_idx = (s.geometry.type == 'Polygon') | (s.geometry.type == 'MultiPolygon') polys = s.geometry[poly_idx] if not polys.empty: - plot_polygon_collection(ax, polys, colors[poly_idx], linewidth=linewidth, **color_kwds) + plot_polygon_collection(ax, polys, colors[poly_idx], False, linewidth=linewidth, **color_kwds) line_idx = (s.geometry.type == 'LineString') | (s.geometry.type == 'MultiLineString') lines = s.geometry[line_idx] if not lines.empty: - plot_linestring_collection(ax, lines, colors[line_idx], linewidth=linewidth, **color_kwds) + plot_linestring_collection(ax, lines, colors[line_idx], False, linewidth=linewidth, **color_kwds) point_idx = (s.geometry.type == 'Point') points = s.geometry[point_idx] @@ -239,7 +322,7 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, geometries can be plotted. column : str (default None) - The name of the column to be plotted. + The name of the column to be plotted. Ignored if `color` is also set. categorical : bool (default False) If False, cmap will reflect numerical values of the @@ -297,10 +380,10 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, warnings.warn("'axes' is deprecated, please use 'ax' instead " "(for consistency with pandas)", FutureWarning) ax = color_kwds.pop('axes') - if 'facecolor' in color_kwds: - warnings.warn("'facecolor' is deprecated, please use 'color' instead " - "(for consistency across geometry types)", FutureWarning) - color = color_kwds.pop('facecolor') if not color else color + if column and color: + warnings.warn("Only specify one of 'column' or 'color'. Using 'color'.", + SyntaxWarning) + column = None import matplotlib.pyplot as plt from matplotlib.lines import Line2D @@ -314,13 +397,15 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, if s[column].dtype is np.dtype('O'): categorical = True + + # Define `values` as a Series if categorical: if cmap is None: cmap = 'Set1' categories = list(set(s[column].values)) categories.sort() valuemap = dict([(k, v) for (v, k) in enumerate(categories)]) - values = [valuemap[k] for k in s[column]] + values = pd.Series([valuemap[k] for k in s[column]]) else: values = s[column] if scheme is not None: @@ -331,43 +416,42 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, binedges = [binning.yb.min()] + binning.bins.tolist() categories = ['{0:.2f} - {1:.2f}'.format(binedges[i], binedges[i+1]) for i in range(len(binedges)-1)] - cmap = norm_cmap(values, cmap, Normalize, cm, vmin=vmin, vmax=vmax) if ax is None: fig, ax = plt.subplots(figsize=figsize) ax.set_aspect('equal') - num_geoms = len(s.index) - if color: - colors = pd.Series([color] * num_geoms) - else: - colors = pd.Series([cmap.to_rgba(v) for v in values]) + mn = values.min() if vmin is None else vmin + mx = values.max() if vmax is None else vmax # plot all Polygons and all components of MultiPolygon in the same collection poly_idx = (s.geometry.type == 'Polygon') | (s.geometry.type == 'MultiPolygon') polys = s.geometry[poly_idx] if not polys.empty: - plot_polygon_collection(ax, polys, colors[poly_idx], linewidth=linewidth, **color_kwds) + plot_polygon_collection(ax, polys, values[poly_idx], True, vmin=mn, vmax=mx, cmap=cmap, linewidth=linewidth, **color_kwds) line_idx = (s.geometry.type == 'LineString') | (s.geometry.type == 'MultiLineString') lines = s.geometry[line_idx] if not lines.empty: - plot_linestring_collection(ax, lines, colors[line_idx], linewidth=linewidth, **color_kwds) + plot_linestring_collection(ax, lines, values[line_idx], True, vmin=mn, vmax=mx, cmap=cmap, linewidth=linewidth, **color_kwds) point_idx = (s.geometry.type == 'Point') points = s.geometry[point_idx] if not points.empty: - plot_point_collection(ax, points, colors[point_idx], **color_kwds) + plot_point_collection(ax, points, values[point_idx], vmin=mn, vmax=mx, cmap=cmap,**color_kwds) if legend and not color: + norm = Normalize(vmin=mn, vmax=mx) + n_cmap = cm.ScalarMappable(norm=norm, cmap=cmap) if categorical: patches = [] for value, cat in enumerate(categories): patches.append(Line2D([0], [0], linestyle="none", marker="o", alpha=color_kwds.get('alpha', 0.5), - markersize=10, markerfacecolor=cmap.to_rgba(value))) + markersize=10, markerfacecolor=n_cmap.to_rgba(value))) ax.legend(patches, categories, numpoints=1, loc='best') else: - ax.get_figure().colorbar(cmap) + n_cmap.set_array([]) + ax.get_figure().colorbar(n_cmap) plt.draw() return ax @@ -417,43 +501,3 @@ def __pysal_choro(values, scheme, k=5): return binning except ImportError: raise ImportError("PySAL is required to use the 'scheme' keyword") - - -def norm_cmap(values, cmap, normalize, cm, vmin=None, vmax=None): - - """ Normalize and set colormap - - Parameters - ---------- - - values - Series or array to be normalized - - cmap - matplotlib Colormap - - normalize - matplotlib.colors.Normalize - - cm - matplotlib.cm - - vmin - Minimum value of colormap. If None, uses min(values). - - vmax - Maximum value of colormap. If None, uses max(values). - - Returns - ------- - n_cmap - mapping of normalized values to colormap (cmap) - - """ - - mn = min(values) if vmin is None else vmin - mx = max(values) if vmax is None else vmax - norm = normalize(vmin=mn, vmax=mx) - n_cmap = cm.ScalarMappable(norm=norm, cmap=cmap) - n_cmap.set_array([]) - return n_cmap diff --git a/tests/test_plotting.py b/tests/test_plotting.py index e6073e5..685f4be 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -5,6 +5,7 @@ import numpy as np import os import shutil import tempfile +import warnings import matplotlib matplotlib.use('Agg', warn=False) @@ -87,8 +88,10 @@ class TestPointPlotting(unittest.TestCase): ax = self.df.plot(color='green') _check_colors(self.N, ax.collections[0], ['green']*self.N) - ax = self.df.plot(column='values', color='green') - _check_colors(self.N, ax.collections[0], ['green']*self.N) + with warnings.catch_warnings(record=True) as _: # don't print warning + # 'color' overrides 'column' + ax = self.df.plot(column='values', color='green') + _check_colors(self.N, ax.collections[0], ['green']*self.N) def test_style_kwargs(self): @@ -103,9 +106,10 @@ class TestPointPlotting(unittest.TestCase): assert ax.collections[0].get_sizes() == [10] def test_legend(self): - # legend ignored if color is given. - ax = self.df.plot(column='values', color='green', legend=True) - assert len(ax.get_figure().axes) == 1 # only the plot, no axis w/ legend + with warnings.catch_warnings(record=True) as _: # don't print warning + # legend ignored if color is given. + ax = self.df.plot(column='values', color='green', legend=True) + assert len(ax.get_figure().axes) == 1 # only the plot, no axis w/ legend # legend ignored if no column is given. ax = self.df.plot(legend=True) @@ -121,6 +125,16 @@ class TestPointPlotting(unittest.TestCase): ### last point == top of colorbar np.testing.assert_array_equal(point_colors[-1], cbar_colors[-1]) + # Categorical legend + ## the colorbar matches the Point colors + ax = self.df.plot(column='values', categorical=True, legend=True) + point_colors = ax.collections[0].get_facecolors() + cbar_colors = ax.get_legend().axes.collections[0].get_facecolors() + ### first point == bottom of colorbar + np.testing.assert_array_equal(point_colors[0], cbar_colors[0]) + ### last point == top of colorbar + np.testing.assert_array_equal(point_colors[-1], cbar_colors[-1]) + class TestLineStringPlotting(unittest.TestCase): @@ -140,8 +154,10 @@ class TestLineStringPlotting(unittest.TestCase): ax = self.df.plot(color='green') _check_colors(self.N, ax.collections[0], ['green']*self.N) - ax = self.df.plot(column='values', color='green') - _check_colors(self.N, ax.collections[0], ['green']*self.N) + with warnings.catch_warnings(record=True) as _: # don't print warning + # 'color' overrides 'column' + ax = self.df.plot(column='values', color='green') + _check_colors(self.N, ax.collections[0], ['green']*self.N) def test_style_kwargs(self): @@ -192,21 +208,42 @@ class TestPolygonPlotting(unittest.TestCase): ax = self.df.plot(color='green') _check_colors(2, ax.collections[0], ['green']*2, alpha=0.5) - ax = self.df.plot(column='values', color='green') - _check_colors(2, ax.collections[0], ['green']*2, alpha=0.5) + with warnings.catch_warnings(record=True) as _: # don't print warning + # 'color' overrides 'values' + ax = self.df.plot(column='values', color='green') + _check_colors(2, ax.collections[0], ['green']*2, alpha=0.5) def test_vmin_vmax(self): # when vmin == vmax, all polygons should be the same color + + # non-categorical + ax = self.df.plot(column='values', categorical=False, vmin=0, vmax=0) + actual_colors = ax.collections[0].get_facecolors() + np.testing.assert_array_equal(actual_colors[0], actual_colors[1]) + + # categorical ax = self.df.plot(column='values', categorical=True, vmin=0, vmax=0) - cmap = get_cmap('Set1', 2) - _check_colors(2, ax.collections[0], cmap([0, 0]), alpha=0.5) + actual_colors = ax.collections[0].get_facecolors() + np.testing.assert_array_equal(actual_colors[0], actual_colors[1]) def test_style_kwargs(self): + # facecolor overrides default cmap when color is not set ax = self.polys.plot(facecolor='k') _check_colors(2, ax.collections[0], ['k']*2, alpha=0.5) + # facecolor overrides more general-purpose color when both are set + ax = self.polys.plot(color='red', facecolor='k') + _check_colors(2, ax.collections[0], ['k']*2, alpha=0.5) + + # edgecolor + ax = self.polys.plot(edgecolor='red') + np.testing.assert_array_equal([(1, 0, 0, 0.5)], ax.collections[0].get_edgecolors()) + + ax = self.df.plot('values', edgecolor='red') + np.testing.assert_array_equal([(1, 0, 0, 0.5)], ax.collections[0].get_edgecolors()) + def test_multipolygons(self): # MultiPolygons From 41b98f59b954d1feb7b9e96aea2c1c20cd0688c7 Mon Sep 17 00:00:00 2001 From: Jeffrey Gerard Date: Wed, 23 Mar 2016 18:16:34 +0100 Subject: [PATCH 07/15] Plot polygon fill separate from edges to ensure no alpha on edges This is to maintain legacy behavior. --- geopandas/plotting.py | 18 +++++++++++++++--- tests/test_plotting.py | 9 +++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/geopandas/plotting.py b/geopandas/plotting.py index 8179316..217f045 100644 --- a/geopandas/plotting.py +++ b/geopandas/plotting.py @@ -286,7 +286,13 @@ def plot_series(s, cmap='Set1', color=None, ax=None, linewidth=1.0, poly_idx = (s.geometry.type == 'Polygon') | (s.geometry.type == 'MultiPolygon') polys = s.geometry[poly_idx] if not polys.empty: - plot_polygon_collection(ax, polys, colors[poly_idx], False, linewidth=linewidth, **color_kwds) + # Plot the fill with default or user-specified alpha, but do not draw outlines. + plot_polygon_collection(ax, polys, colors[poly_idx], False, linewidth=0, **color_kwds) + # Draw the edges, fully opaque, but no facecolor. + edges_kwds = color_kwds.copy() + edges_kwds['alpha'] = 1 + edges_kwds['facecolor'] = 'none' + plot_polygon_collection(ax, polys, colors[poly_idx], False, linewidth=linewidth, **edges_kwds) line_idx = (s.geometry.type == 'LineString') | (s.geometry.type == 'MultiLineString') lines = s.geometry[line_idx] @@ -427,7 +433,13 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, poly_idx = (s.geometry.type == 'Polygon') | (s.geometry.type == 'MultiPolygon') polys = s.geometry[poly_idx] if not polys.empty: - plot_polygon_collection(ax, polys, values[poly_idx], True, vmin=mn, vmax=mx, cmap=cmap, linewidth=linewidth, **color_kwds) + # Plot the fill with default or user-specified alpha, but do not draw outlines. + plot_polygon_collection(ax, polys, values[poly_idx], True, vmin=mn, vmax=mx, cmap=cmap, linewidth=0, **color_kwds) + # Draw the edges, fully opaque, but no facecolor. + edges_kwds = color_kwds.copy() + edges_kwds['alpha'] = 1 + edges_kwds['facecolor'] = 'none' + plot_polygon_collection(ax, polys, values[poly_idx], True, vmin=mn, vmax=mx, cmap=cmap, linewidth=linewidth, **edges_kwds) line_idx = (s.geometry.type == 'LineString') | (s.geometry.type == 'MultiLineString') lines = s.geometry[line_idx] @@ -437,7 +449,7 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, point_idx = (s.geometry.type == 'Point') points = s.geometry[point_idx] if not points.empty: - plot_point_collection(ax, points, values[point_idx], vmin=mn, vmax=mx, cmap=cmap,**color_kwds) + plot_point_collection(ax, points, values[point_idx], vmin=mn, vmax=mx, cmap=cmap, **color_kwds) if legend and not color: norm = Normalize(vmin=mn, vmax=mx) diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 685f4be..e6c03b4 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -275,18 +275,19 @@ class TestNonuniformGeometryPlotting(unittest.TestCase): ax = self.series.plot(cmap='RdYlGn') cmap = get_cmap('RdYlGn', 3) _check_colors(1, ax.collections[0], [cmap(0)], alpha=0.5) # polygon gets extra alpha. See #266 - _check_colors(1, ax.collections[1], [cmap(1)], alpha=1) # line - _check_colors(1, ax.collections[2], [cmap(2)], alpha=1) # point + # N.B. ax.collections[1] contains the edges of the polygon + _check_colors(1, ax.collections[2], [cmap(1)], alpha=1) # line + _check_colors(1, ax.collections[3], [cmap(2)], alpha=1) # point def test_style_kwargs(self): # markersize -> only the Point gets it ax = self.series.plot(markersize=10) - assert ax.collections[2].get_sizes() == [10] + assert ax.collections[3].get_sizes() == [10] ax = self.df.plot(markersize=10) - assert ax.collections[2].get_sizes() == [10] + assert ax.collections[3].get_sizes() == [10] class TestPySALPlotting(unittest.TestCase): From 553321789e0474c095ad760b7c86746e2445391b Mon Sep 17 00:00:00 2001 From: Jeffrey Gerard Date: Thu, 24 Mar 2016 10:06:36 +0100 Subject: [PATCH 08/15] Compatibility with older versions of matplotlib --- geopandas/plotting.py | 5 +++++ requirements.test.txt | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/geopandas/plotting.py b/geopandas/plotting.py index 217f045..2576be8 100644 --- a/geopandas/plotting.py +++ b/geopandas/plotting.py @@ -193,6 +193,11 @@ def plot_point_collection(ax, geoms, colors_or_values, """ x = [p.x for p in geoms] y = [p.y for p in geoms] + + # matplotlib ax.scatter requires RGBA color specifications to be a single 2D + # array, NOT merely a list of 1D arrays. This reshapes that if necessary, + # having no effect on 1D arrays of values. + colors_or_values = np.array([element for _, element in enumerate(colors_or_values)]) collection = ax.scatter(x, y, c=colors_or_values, vmin=vmin, vmax=vmax, cmap=cmap, marker=marker, s=markersize, **kwargs) diff --git a/requirements.test.txt b/requirements.test.txt index 7c18caa..02991bd 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -1,7 +1,7 @@ psycopg2>=2.5.1 SQLAlchemy>=0.8.3 geopy==1.10.0 -matplotlib>=1.5.0 +matplotlib>=1.2.1 descartes>=1.0 mock>=1.0.1 # technically not need for python >= 3.3 pytest-cov From e0a06f8b227e828da127ed45ec54a1b6fbf47408 Mon Sep 17 00:00:00 2001 From: Jeffrey Gerard Date: Thu, 24 Mar 2016 10:38:17 +0100 Subject: [PATCH 09/15] Tabs to spaces --- tests/test_plotting.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/test_plotting.py b/tests/test_plotting.py index e6c03b4..82cb9bd 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -314,18 +314,19 @@ class TestPySALPlotting(unittest.TestCase): def _check_colors(N, collection, expected_colors, alpha=None): """ Asserts that the members of `collection` match the `expected_colors` (in order) - Parameters - ---------- - N : the number of geometries believed to be in collection. + Parameters + ---------- + N : int + The number of geometries believed to be in collection. matplotlib.collection is implemented such that the number of geoms in `collection` doesn't have to match the number of colors assignments in the collection: the colors will cycle to meet the needs of the geoms. `N` helps us resolve this. - collection : matplotlib.collections.Collection + collection : matplotlib.collections.Collection The colors of this collection's patches are read from `collection.get_facecolors()` - expected_colors : sequence of RGBA tuples - alpha : float (optional) - If set, this alpha transparency will be applied to the `expected_colors`. + expected_colors : sequence of RGBA tuples + alpha : float (optional) + If set, this alpha transparency will be applied to the `expected_colors`. (Any transparency on the `collecton` is assumed to be set in its own facecolor RGBA tuples.) """ From b50df1c3c54125027293694b00382a7a1c227ae9 Mon Sep 17 00:00:00 2001 From: Jeffrey Gerard Date: Thu, 24 Mar 2016 12:56:11 +0100 Subject: [PATCH 10/15] Fix regression when plotting pysal scheme --- geopandas/plotting.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/geopandas/plotting.py b/geopandas/plotting.py index 2576be8..0620091 100644 --- a/geopandas/plotting.py +++ b/geopandas/plotting.py @@ -76,10 +76,6 @@ def plot_polygon_collection(ax, geoms, colors_or_values, plot_values, collection = PatchCollection([PolygonPatch(poly) for poly in components], linewidth=linewidth, edgecolor=edgecolor, alpha=alpha, **kwargs) - # TODO: draw polygon interior(s) in the right color. - # Better question: what is the old code on master trying to do, - # adding the Descartes Patch and then separately drawing the boundaries? - # Answer: linewidth=0 because boundaries are drawn separately if plot_values: collection.set_array(np.array(component_colors_or_values)) @@ -421,7 +417,7 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, values = s[column] if scheme is not None: binning = __pysal_choro(values, scheme, k=k) - values = binning.yb + values = pd.Series(binning.yb) # set categorical to True for creating the legend categorical = True binedges = [binning.yb.min()] + binning.bins.tolist() From edc72c8bf68c7669014ee1d5cc3260a22d67736e Mon Sep 17 00:00:00 2001 From: Jeffrey Gerard Date: Thu, 24 Mar 2016 13:07:16 +0100 Subject: [PATCH 11/15] Fix regression for black edges when plotting column values --- geopandas/plotting.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/geopandas/plotting.py b/geopandas/plotting.py index 0620091..2f375b0 100644 --- a/geopandas/plotting.py +++ b/geopandas/plotting.py @@ -440,7 +440,9 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, edges_kwds = color_kwds.copy() edges_kwds['alpha'] = 1 edges_kwds['facecolor'] = 'none' - plot_polygon_collection(ax, polys, values[poly_idx], True, vmin=mn, vmax=mx, cmap=cmap, linewidth=linewidth, **edges_kwds) + # Setting plot_values=False would cause the array values' colors to override edgecolor. + # By setting color instead, matplotlib will respect edgecolor if set. + plot_polygon_collection(ax, polys, ['black']*len(polys), False, linewidth=linewidth, **edges_kwds) line_idx = (s.geometry.type == 'LineString') | (s.geometry.type == 'MultiLineString') lines = s.geometry[line_idx] From 1969624bb14787b88d2663941030d5440790da02 Mon Sep 17 00:00:00 2001 From: Jeffrey Gerard Date: Wed, 30 Mar 2016 13:22:22 +0200 Subject: [PATCH 12/15] Remove unused imports --- tests/test_plotting.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 82cb9bd..d947b9f 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -2,17 +2,11 @@ from __future__ import absolute_import, division import itertools import numpy as np -import os -import shutil -import tempfile import warnings import matplotlib matplotlib.use('Agg', warn=False) -from matplotlib.pyplot import Artist, savefig, clf, cm, get_cmap -from matplotlib.testing.noseclasses import ImageComparisonFailure -from matplotlib.testing.compare import compare_images -from numpy import cos, sin, pi +from matplotlib.pyplot import get_cmap from shapely.affinity import rotate from shapely.geometry import MultiPolygon, Polygon, LineString, Point from six.moves import xrange @@ -330,7 +324,6 @@ def _check_colors(N, collection, expected_colors, alpha=None): (Any transparency on the `collecton` is assumed to be set in its own facecolor RGBA tuples.) """ - from matplotlib.lines import Line2D import matplotlib.colors as colors conv = colors.colorConverter From 0af50220a734992e1c1644fcc173cc3023ba5a55 Mon Sep 17 00:00:00 2001 From: Jeffrey Gerard Date: Tue, 28 Jun 2016 14:46:02 +0200 Subject: [PATCH 13/15] PEP8 clean-up --- geopandas/plotting.py | 56 ++++++++++++++++++++++---------- geopandas/tests/test_plotting.py | 39 +++++++++++++--------- 2 files changed, 61 insertions(+), 34 deletions(-) diff --git a/geopandas/plotting.py b/geopandas/plotting.py index 2f375b0..ac1e188 100644 --- a/geopandas/plotting.py +++ b/geopandas/plotting.py @@ -24,7 +24,9 @@ def _flatten_multi_geoms(geoms, colors): component_colors : list of whatever type `colors` contains """ components, component_colors = [], [] - assert len(geoms) == len(colors) # precondition, so zip can't short-circuit + + # precondition, so zip can't short-circuit + assert len(geoms) == len(colors) for geom, color in zip(geoms, colors): if geom.type.startswith('Multi'): for poly in geom: @@ -68,14 +70,15 @@ def plot_polygon_collection(ax, geoms, colors_or_values, plot_values, from descartes.patch import PolygonPatch from matplotlib.collections import PatchCollection - components, component_colors_or_values = _flatten_multi_geoms(geoms, colors_or_values) + components, component_colors_or_values = _flatten_multi_geoms( + geoms, colors_or_values) # PatchCollection does not accept some kwargs. if 'markersize' in kwargs: del kwargs['markersize'] collection = PatchCollection([PolygonPatch(poly) for poly in components], - linewidth=linewidth, edgecolor=edgecolor, - alpha=alpha, **kwargs) + linewidth=linewidth, edgecolor=edgecolor, + alpha=alpha, **kwargs) if plot_values: collection.set_array(np.array(component_colors_or_values)) @@ -131,7 +134,8 @@ def plot_linestring_collection(ax, geoms, colors_or_values, plot_values, from matplotlib.collections import LineCollection - components, component_colors_or_values = _flatten_multi_geoms(geoms, colors_or_values) + components, component_colors_or_values = _flatten_multi_geoms( + geoms, colors_or_values) # LineCollection does not accept some kwargs. if 'markersize' in kwargs: @@ -193,7 +197,8 @@ def plot_point_collection(ax, geoms, colors_or_values, # matplotlib ax.scatter requires RGBA color specifications to be a single 2D # array, NOT merely a list of 1D arrays. This reshapes that if necessary, # having no effect on 1D arrays of values. - colors_or_values = np.array([element for _, element in enumerate(colors_or_values)]) + colors_or_values = np.array([element + for _, element in enumerate(colors_or_values)]) collection = ax.scatter(x, y, c=colors_or_values, vmin=vmin, vmax=vmax, cmap=cmap, marker=marker, s=markersize, **kwargs) @@ -284,21 +289,27 @@ def plot_series(s, cmap='Set1', color=None, ax=None, linewidth=1.0, color_generator = gencolor(len(s), colormap=cmap) colors = pd.Series([next(color_generator) for _ in xrange(num_geoms)]) + # plot all Polygons and all MultiPolygon components in the same collection poly_idx = (s.geometry.type == 'Polygon') | (s.geometry.type == 'MultiPolygon') polys = s.geometry[poly_idx] if not polys.empty: - # Plot the fill with default or user-specified alpha, but do not draw outlines. - plot_polygon_collection(ax, polys, colors[poly_idx], False, linewidth=0, **color_kwds) + # Plot the fill with default or user-specified alpha, but do not draw + # outlines. + plot_polygon_collection(ax, polys, colors[poly_idx], False, + linewidth=0, **color_kwds) # Draw the edges, fully opaque, but no facecolor. edges_kwds = color_kwds.copy() edges_kwds['alpha'] = 1 edges_kwds['facecolor'] = 'none' - plot_polygon_collection(ax, polys, colors[poly_idx], False, linewidth=linewidth, **edges_kwds) + plot_polygon_collection(ax, polys, colors[poly_idx], False, + linewidth=linewidth, **edges_kwds) + # plot all LineStrings and MultiLineString components in same collection line_idx = (s.geometry.type == 'LineString') | (s.geometry.type == 'MultiLineString') lines = s.geometry[line_idx] if not lines.empty: - plot_linestring_collection(ax, lines, colors[line_idx], False, linewidth=linewidth, **color_kwds) + plot_linestring_collection(ax, lines, colors[line_idx], False, + linewidth=linewidth, **color_kwds) point_idx = (s.geometry.type == 'Point') points = s.geometry[point_idx] @@ -430,29 +441,38 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, mn = values.min() if vmin is None else vmin mx = values.max() if vmax is None else vmax - # plot all Polygons and all components of MultiPolygon in the same collection + # plot all Polygons and all MultiPolygon components in the same collection poly_idx = (s.geometry.type == 'Polygon') | (s.geometry.type == 'MultiPolygon') polys = s.geometry[poly_idx] if not polys.empty: - # Plot the fill with default or user-specified alpha, but do not draw outlines. - plot_polygon_collection(ax, polys, values[poly_idx], True, vmin=mn, vmax=mx, cmap=cmap, linewidth=0, **color_kwds) + # Plot the fill with default or user-specified alpha, but do not draw + # outlines. + plot_polygon_collection(ax, polys, values[poly_idx], True, + vmin=mn, vmax=mx, cmap=cmap, + linewidth=0, **color_kwds) # Draw the edges, fully opaque, but no facecolor. edges_kwds = color_kwds.copy() edges_kwds['alpha'] = 1 edges_kwds['facecolor'] = 'none' - # Setting plot_values=False would cause the array values' colors to override edgecolor. - # By setting color instead, matplotlib will respect edgecolor if set. - plot_polygon_collection(ax, polys, ['black']*len(polys), False, linewidth=linewidth, **edges_kwds) + # Setting plot_values=False would cause the array values' colors to + # override edgecolor. By setting color instead, matplotlib will respect + # edgecolor if set. + plot_polygon_collection(ax, polys, ['black'] * len(polys), False, + linewidth=linewidth, **edges_kwds) + # plot all LineStrings and MultiLineString components in same collection line_idx = (s.geometry.type == 'LineString') | (s.geometry.type == 'MultiLineString') lines = s.geometry[line_idx] if not lines.empty: - plot_linestring_collection(ax, lines, values[line_idx], True, vmin=mn, vmax=mx, cmap=cmap, linewidth=linewidth, **color_kwds) + plot_linestring_collection(ax, lines, values[line_idx], True, + vmin=mn, vmax=mx, cmap=cmap, + linewidth=linewidth, **color_kwds) point_idx = (s.geometry.type == 'Point') points = s.geometry[point_idx] if not points.empty: - plot_point_collection(ax, points, values[point_idx], vmin=mn, vmax=mx, cmap=cmap, **color_kwds) + plot_point_collection(ax, points, values[point_idx], + vmin=mn, vmax=mx, cmap=cmap, **color_kwds) if legend and not color: norm = Normalize(vmin=mn, vmax=mx) diff --git a/geopandas/tests/test_plotting.py b/geopandas/tests/test_plotting.py index d947b9f..9ef2949 100644 --- a/geopandas/tests/test_plotting.py +++ b/geopandas/tests/test_plotting.py @@ -82,7 +82,7 @@ class TestPointPlotting(unittest.TestCase): ax = self.df.plot(color='green') _check_colors(self.N, ax.collections[0], ['green']*self.N) - with warnings.catch_warnings(record=True) as _: # don't print warning + with warnings.catch_warnings(record=True) as _: # don't print warning # 'color' overrides 'column' ax = self.df.plot(column='values', color='green') _check_colors(self.N, ax.collections[0], ['green']*self.N) @@ -100,14 +100,14 @@ class TestPointPlotting(unittest.TestCase): assert ax.collections[0].get_sizes() == [10] def test_legend(self): - with warnings.catch_warnings(record=True) as _: # don't print warning + with warnings.catch_warnings(record=True) as _: # don't print warning # legend ignored if color is given. ax = self.df.plot(column='values', color='green', legend=True) - assert len(ax.get_figure().axes) == 1 # only the plot, no axis w/ legend + assert len(ax.get_figure().axes) == 1 # no separate legend axis # legend ignored if no column is given. ax = self.df.plot(legend=True) - assert len(ax.get_figure().axes) == 1 # only the plot, no axis w/ legend + assert len(ax.get_figure().axes) == 1 # no separate legend axis # Continuous legend ## the colorbar matches the Point colors @@ -148,7 +148,7 @@ class TestLineStringPlotting(unittest.TestCase): ax = self.df.plot(color='green') _check_colors(self.N, ax.collections[0], ['green']*self.N) - with warnings.catch_warnings(record=True) as _: # don't print warning + with warnings.catch_warnings(record=True) as _: # don't print warning # 'color' overrides 'column' ax = self.df.plot(column='values', color='green') _check_colors(self.N, ax.collections[0], ['green']*self.N) @@ -162,20 +162,24 @@ class TestLineStringPlotting(unittest.TestCase): { 'dashed', 'dotted', 'dashdot', 'solid' }. """ from matplotlib.backend_bases import GraphicsContextBase - reverse_idx = dict((v, k) for k, v in GraphicsContextBase.dashd.items()) + reverse_idx = dict((v, k) + for k, v in GraphicsContextBase.dashd.items()) return reverse_idx[tup] # linestyle ax = self.lines.plot(linestyle='dashed') - ls = [linestyle_tuple_to_string(l) for l in ax.collections[0].get_linestyles()] + ls = [linestyle_tuple_to_string(l) + for l in ax.collections[0].get_linestyles()] assert ls == ['dashed'] ax = self.df.plot(linestyle='dashed') - ls = [linestyle_tuple_to_string(l) for l in ax.collections[0].get_linestyles()] + ls = [linestyle_tuple_to_string(l) + for l in ax.collections[0].get_linestyles()] assert ls == ['dashed'] ax = self.df.plot(column='values', linestyle='dashed') - ls = [linestyle_tuple_to_string(l) for l in ax.collections[0].get_linestyles()] + ls = [linestyle_tuple_to_string(l) + for l in ax.collections[0].get_linestyles()] assert ls == ['dashed'] @@ -202,7 +206,7 @@ class TestPolygonPlotting(unittest.TestCase): ax = self.df.plot(color='green') _check_colors(2, ax.collections[0], ['green']*2, alpha=0.5) - with warnings.catch_warnings(record=True) as _: # don't print warning + with warnings.catch_warnings(record=True) as _: # don't print warning # 'color' overrides 'values' ax = self.df.plot(column='values', color='green') _check_colors(2, ax.collections[0], ['green']*2, alpha=0.5) @@ -233,10 +237,12 @@ class TestPolygonPlotting(unittest.TestCase): # edgecolor ax = self.polys.plot(edgecolor='red') - np.testing.assert_array_equal([(1, 0, 0, 0.5)], ax.collections[0].get_edgecolors()) + np.testing.assert_array_equal([(1, 0, 0, 0.5)], + ax.collections[0].get_edgecolors()) ax = self.df.plot('values', edgecolor='red') - np.testing.assert_array_equal([(1, 0, 0, 0.5)], ax.collections[0].get_edgecolors()) + np.testing.assert_array_equal([(1, 0, 0, 0.5)], + ax.collections[0].get_edgecolors()) def test_multipolygons(self): @@ -268,12 +274,12 @@ class TestNonuniformGeometryPlotting(unittest.TestCase): ax = self.series.plot(cmap='RdYlGn') cmap = get_cmap('RdYlGn', 3) - _check_colors(1, ax.collections[0], [cmap(0)], alpha=0.5) # polygon gets extra alpha. See #266 + # polygon gets extra alpha. See #266 + _check_colors(1, ax.collections[0], [cmap(0)], alpha=0.5) # N.B. ax.collections[1] contains the edges of the polygon _check_colors(1, ax.collections[2], [cmap(1)], alpha=1) # line _check_colors(1, ax.collections[3], [cmap(2)], alpha=1) # point - def test_style_kwargs(self): # markersize -> only the Point gets it @@ -298,7 +304,7 @@ class TestPySALPlotting(unittest.TestCase): def test_legend(self): ax = self.tracts.plot(column='CRIME', scheme='QUANTILES', k=3, - cmap='OrRd', legend=True) + cmap='OrRd', legend=True) labels = [t.get_text() for t in ax.get_legend().get_texts()] expected = [u'0.00 - 26.07', u'26.07 - 41.97', u'41.97 - 68.89'] @@ -330,7 +336,8 @@ def _check_colors(N, collection, expected_colors, alpha=None): # Convert 2D numpy array to a list of RGBA tuples. actual_colors = list(collection.get_facecolors()) actual_colors = map(tuple, actual_colors) - all_actual_colors = list(itertools.islice(itertools.cycle(actual_colors), N)) + all_actual_colors = list(itertools.islice( + itertools.cycle(actual_colors), N)) for actual, expected in zip(all_actual_colors, expected_colors): assert actual == conv.to_rgba(expected, alpha=alpha), \ From 0b6dfd096eb0912166eaa79e5298c7f0205f7f5b Mon Sep 17 00:00:00 2001 From: Jeffrey Gerard Date: Mon, 11 Jul 2016 12:16:12 +0200 Subject: [PATCH 14/15] Fix plotting nontrivially indexed Series Now uses numpy arrays, not array-likes, to ensure treatment as Boolean indexes. --- geopandas/plotting.py | 26 ++++++++++++++++---------- geopandas/tests/test_plotting.py | 5 +++-- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/geopandas/plotting.py b/geopandas/plotting.py index ac1e188..2f4951c 100644 --- a/geopandas/plotting.py +++ b/geopandas/plotting.py @@ -284,13 +284,14 @@ def plot_series(s, cmap='Set1', color=None, ax=None, linewidth=1.0, num_geoms = len(s.index) if color: - colors = pd.Series([color] * num_geoms) + colors = np.array([color] * num_geoms) else: color_generator = gencolor(len(s), colormap=cmap) - colors = pd.Series([next(color_generator) for _ in xrange(num_geoms)]) + colors = np.array([next(color_generator) for _ in xrange(num_geoms)]) # plot all Polygons and all MultiPolygon components in the same collection - poly_idx = (s.geometry.type == 'Polygon') | (s.geometry.type == 'MultiPolygon') + poly_idx = np.array( + (s.geometry.type == 'Polygon') | (s.geometry.type == 'MultiPolygon')) polys = s.geometry[poly_idx] if not polys.empty: # Plot the fill with default or user-specified alpha, but do not draw @@ -305,13 +306,15 @@ def plot_series(s, cmap='Set1', color=None, ax=None, linewidth=1.0, linewidth=linewidth, **edges_kwds) # plot all LineStrings and MultiLineString components in same collection - line_idx = (s.geometry.type == 'LineString') | (s.geometry.type == 'MultiLineString') + line_idx = np.array( + (s.geometry.type == 'LineString') | + (s.geometry.type == 'MultiLineString')) lines = s.geometry[line_idx] if not lines.empty: plot_linestring_collection(ax, lines, colors[line_idx], False, linewidth=linewidth, **color_kwds) - point_idx = (s.geometry.type == 'Point') + point_idx = np.array(s.geometry.type == 'Point') points = s.geometry[point_idx] if not points.empty: plot_point_collection(ax, points, colors[point_idx], **color_kwds) @@ -423,12 +426,12 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, categories = list(set(s[column].values)) categories.sort() valuemap = dict([(k, v) for (v, k) in enumerate(categories)]) - values = pd.Series([valuemap[k] for k in s[column]]) + values = np.array([valuemap[k] for k in s[column]]) else: values = s[column] if scheme is not None: binning = __pysal_choro(values, scheme, k=k) - values = pd.Series(binning.yb) + values = np.array(binning.yb) # set categorical to True for creating the legend categorical = True binedges = [binning.yb.min()] + binning.bins.tolist() @@ -442,7 +445,8 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, mx = values.max() if vmax is None else vmax # plot all Polygons and all MultiPolygon components in the same collection - poly_idx = (s.geometry.type == 'Polygon') | (s.geometry.type == 'MultiPolygon') + poly_idx = np.array( + (s.geometry.type == 'Polygon') | (s.geometry.type == 'MultiPolygon')) polys = s.geometry[poly_idx] if not polys.empty: # Plot the fill with default or user-specified alpha, but do not draw @@ -461,14 +465,16 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, linewidth=linewidth, **edges_kwds) # plot all LineStrings and MultiLineString components in same collection - line_idx = (s.geometry.type == 'LineString') | (s.geometry.type == 'MultiLineString') + line_idx = np.array( + (s.geometry.type == 'LineString') | + (s.geometry.type == 'MultiLineString')) lines = s.geometry[line_idx] if not lines.empty: plot_linestring_collection(ax, lines, values[line_idx], True, vmin=mn, vmax=mx, cmap=cmap, linewidth=linewidth, **color_kwds) - point_idx = (s.geometry.type == 'Point') + point_idx = np.array(s.geometry.type == 'Point') points = s.geometry[point_idx] if not points.empty: plot_point_collection(ax, points, values[point_idx], diff --git a/geopandas/tests/test_plotting.py b/geopandas/tests/test_plotting.py index 9ef2949..ac99e67 100644 --- a/geopandas/tests/test_plotting.py +++ b/geopandas/tests/test_plotting.py @@ -137,7 +137,8 @@ class TestLineStringPlotting(unittest.TestCase): self.N = 10 values = np.arange(self.N) self.lines = GeoSeries([LineString([(0, i), (4, i+0.5), (9, i)]) - for i in xrange(self.N)]) + for i in xrange(self.N)], + index=list('ABCDEFGHIJ')) self.df = GeoDataFrame({'geometry': self.lines, 'values': values}) def test_single_color(self): @@ -189,7 +190,7 @@ class TestPolygonPlotting(unittest.TestCase): t1 = Polygon([(0, 0), (1, 0), (1, 1)]) t2 = Polygon([(1, 0), (2, 0), (2, 1)]) - self.polys = GeoSeries([t1, t2]) + self.polys = GeoSeries([t1, t2], index=list('AB')) self.df = GeoDataFrame({'geometry': self.polys, 'values': [0, 1]}) multipoly1 = MultiPolygon([t1, t2]) From f9395a036eccf7c0ff6220cf6653135330bf3bb5 Mon Sep 17 00:00:00 2001 From: Jeffrey Gerard Date: Mon, 11 Jul 2016 19:47:33 +0200 Subject: [PATCH 15/15] speed improvement for polygons with linewidth=0 or alpha=1 Thanks to @mlyons-tcc for suggesting the optimization. --- geopandas/plotting.py | 65 ++++++++++++++++++++------------ geopandas/tests/test_plotting.py | 16 ++++++++ 2 files changed, 57 insertions(+), 24 deletions(-) diff --git a/geopandas/plotting.py b/geopandas/plotting.py index 2f4951c..02022f4 100644 --- a/geopandas/plotting.py +++ b/geopandas/plotting.py @@ -294,16 +294,24 @@ def plot_series(s, cmap='Set1', color=None, ax=None, linewidth=1.0, (s.geometry.type == 'Polygon') | (s.geometry.type == 'MultiPolygon')) polys = s.geometry[poly_idx] if not polys.empty: - # Plot the fill with default or user-specified alpha, but do not draw - # outlines. - plot_polygon_collection(ax, polys, colors[poly_idx], False, - linewidth=0, **color_kwds) - # Draw the edges, fully opaque, but no facecolor. - edges_kwds = color_kwds.copy() - edges_kwds['alpha'] = 1 - edges_kwds['facecolor'] = 'none' - plot_polygon_collection(ax, polys, colors[poly_idx], False, - linewidth=linewidth, **edges_kwds) + # Legacy behavior applies alpha to fill but not to edges. This requires + # plotting them separately (at big performance expense). + if linewidth > 0 and color_kwds.get('alpha', 0.5) < 1.0: + # Plot the fill with default or user-specified alpha, but do not + # draw outlines. + plot_polygon_collection(ax, polys, colors[poly_idx], False, + linewidth=0, **color_kwds) + # Draw the edges, fully opaque, but no facecolor. + edges_kwds = color_kwds.copy() + edges_kwds['alpha'] = 1 + edges_kwds['facecolor'] = 'none' + plot_polygon_collection(ax, polys, colors[poly_idx], False, + linewidth=linewidth, **edges_kwds) + else: + # Optimization: if no alpha on fill, or if no edges, we can plot + # everything in one go. + plot_polygon_collection(ax, polys, colors[poly_idx], False, + linewidth=linewidth, **color_kwds) # plot all LineStrings and MultiLineString components in same collection line_idx = np.array( @@ -449,20 +457,29 @@ def plot_dataframe(s, column=None, cmap=None, color=None, linewidth=1.0, (s.geometry.type == 'Polygon') | (s.geometry.type == 'MultiPolygon')) polys = s.geometry[poly_idx] if not polys.empty: - # Plot the fill with default or user-specified alpha, but do not draw - # outlines. - plot_polygon_collection(ax, polys, values[poly_idx], True, - vmin=mn, vmax=mx, cmap=cmap, - linewidth=0, **color_kwds) - # Draw the edges, fully opaque, but no facecolor. - edges_kwds = color_kwds.copy() - edges_kwds['alpha'] = 1 - edges_kwds['facecolor'] = 'none' - # Setting plot_values=False would cause the array values' colors to - # override edgecolor. By setting color instead, matplotlib will respect - # edgecolor if set. - plot_polygon_collection(ax, polys, ['black'] * len(polys), False, - linewidth=linewidth, **edges_kwds) + # Legacy behavior applies alpha to fill but not to edges. This requires + # plotting them separately (at big performance expense). + if linewidth > 0 and color_kwds.get('alpha', 0.5) < 1.0: + # Plot the fill with default or user-specified alpha, but do not + # draw outlines. + plot_polygon_collection(ax, polys, values[poly_idx], True, + vmin=mn, vmax=mx, cmap=cmap, + linewidth=0, **color_kwds) + # Draw the edges, fully opaque, but no facecolor. + edges_kwds = color_kwds.copy() + edges_kwds['alpha'] = 1 + edges_kwds['facecolor'] = 'none' + # Setting plot_values=False would cause the array values' colors to + # override edgecolor. By setting color instead, matplotlib will + # respect edgecolor if set. + plot_polygon_collection(ax, polys, ['black'] * len(polys), False, + linewidth=linewidth, **edges_kwds) + else: + # Optimization: if no alpha on fill, or if no edges, we can plot + # everything in one go. + plot_polygon_collection(ax, polys, values[poly_idx], True, + vmin=mn, vmax=mx, cmap=cmap, + linewidth=linewidth, **color_kwds) # plot all LineStrings and MultiLineString components in same collection line_idx = np.array( diff --git a/geopandas/tests/test_plotting.py b/geopandas/tests/test_plotting.py index ac99e67..f21287f 100644 --- a/geopandas/tests/test_plotting.py +++ b/geopandas/tests/test_plotting.py @@ -212,6 +212,22 @@ class TestPolygonPlotting(unittest.TestCase): ax = self.df.plot(column='values', color='green') _check_colors(2, ax.collections[0], ['green']*2, alpha=0.5) + def test_optimization(self): + + # when linewidth=0 or no alpha, we don't have to plot polys twice + + ax = self.polys.plot(linewidth=0) + assert len(ax.collections) == 1 # only plotted once + + ax = self.polys.plot(alpha=1) + assert len(ax.collections) == 1 # only plotted once + + ax = self.df.plot(linewidth=0) + assert len(ax.collections) == 1 # only plotted once + + ax = self.df.plot(alpha=1) + assert len(ax.collections) == 1 # only plotted once + def test_vmin_vmax(self): # when vmin == vmax, all polygons should be the same color