Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add polygon rendering support based on spatialpandas extension arrays #826

Merged
merged 14 commits into from
Dec 11, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ jobs:
- doit env_create $CHANS_DEV --python=$PYTHON_VERSION --name=$PYTHON_VERSION
- source activate $PYTHON_VERSION
- doit develop_install $CHANS_DEV $OPTS
# Install spatialpandas here because it's python 3 only and requires
# conda-forge for some dependencies
- if [[ "$PYTHON_VERSION" != "2.7" ]]; then
conda install -c pyviz/label/dev -c conda-forge spatialpandas;
fi
- doit env_capture
script:
- doit test_all
Expand Down
1 change: 0 additions & 1 deletion datashader/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from . import transfer_functions as tf # noqa (API import)
from . import data_libraries # noqa (API import)


# Make RaggedArray pandas extension array available for
# pandas >= 0.24.0 is installed
from pandas import __version__ as pandas_version
Expand Down
211 changes: 168 additions & 43 deletions datashader/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,18 @@ def validate(self, range):
_axis_lookup = {'linear': LinearAxis(), 'log': LogAxis()}


def validate_xy_or_geometry(glyph, x, y, geometry):
if (geometry is None and (x is None or y is None) or
geometry is not None and (x is not None or y is not None)):
raise ValueError("""
{glyph} coordinates may be specified by providing both the x and y arguments, or by
providing the geometry argument. Received:
x: {x}
y: {y}
geometry: {geometry}
""".format(glyph=glyph, x=repr(x), y=repr(y), geometry=repr(geometry)))


class Canvas(object):
"""An abstract canvas representing the space in which to bin.

Expand All @@ -157,34 +169,59 @@ def __init__(self, plot_width=600, plot_height=600,
self.x_axis = _axis_lookup[x_axis_type]
self.y_axis = _axis_lookup[y_axis_type]

def points(self, source, x, y, agg=None):
def points(self, source, x=None, y=None, agg=None, geometry=None):
"""Compute a reduction by pixel, mapping data to pixels as points.

Parameters
----------
source : pandas.DataFrame, dask.DataFrame, or xarray.DataArray/Dataset
The input datasource.
x, y : str
Column names for the x and y coordinates of each point.
Column names for the x and y coordinates of each point. If provided,
the geometry argument may not also be provided.
agg : Reduction, optional
Reduction to compute. Default is ``count()``.
geometry: str
Column name of a PointsArray of the coordinates of each point. If provided,
the x and y arguments may not also be provided.
"""
from .glyphs import Point
from .glyphs import Point, MultiPointGeometry
from .reductions import count as count_rdn

validate_xy_or_geometry('Point', x, y, geometry)

if agg is None:
agg = count_rdn()

if (isinstance(source, SpatialPointsFrame) and
source.spatial is not None and
source.spatial.x == x and source.spatial.y == y and
self.x_range is not None and self.y_range is not None):
# Handle down-selecting of SpatialPointsFrame
if geometry is None:
if (isinstance(source, SpatialPointsFrame) and
source.spatial is not None and
source.spatial.x == x and source.spatial.y == y and
self.x_range is not None and self.y_range is not None):

source = source.spatial_query(
x_range=self.x_range, y_range=self.y_range)
source = source.spatial_query(
x_range=self.x_range, y_range=self.y_range)
glyph = Point(x, y)
else:
from spatialpandas import GeoDataFrame
from spatialpandas.dask import DaskGeoDataFrame
if isinstance(source, DaskGeoDataFrame):
# Downselect partitions to those that may contain points in viewport
x_range = self.x_range if self.x_range is not None else (None, None)
y_range = self.y_range if self.y_range is not None else (None, None)
source = source.cx_partitions[slice(*x_range), slice(*y_range)]
elif not isinstance(source, GeoDataFrame):
raise ValueError(
"source must be an instance of spatialpandas.GeoDataFrame or \n"
"spatialpandas.dask.DaskGeoDataFrame.\n"
" Received value of type {typ}".format(typ=type(source)))

glyph = MultiPointGeometry(geometry)

return bypixel(source, self, Point(x, y), agg)
return bypixel(source, self, glyph, agg)

def line(self, source, x, y, agg=None, axis=0):
def line(self, source, x=None, y=None, agg=None, axis=0, geometry=None):
"""Compute a reduction by pixel, mapping data to pixels as one or
more lines.

Expand Down Expand Up @@ -215,6 +252,9 @@ def line(self, source, x, y, agg=None, axis=0):
all rows in source
* 1: Draw one line per row in source using data from the
specified columns
geometry : str
Column name of a LinesArray of the coordinates of each line. If provided,
the x and y arguments may not also be provided.

Examples
--------
Expand Down Expand Up @@ -284,55 +324,74 @@ def line(self, source, x, y, agg=None, axis=0):
"""
from .glyphs import (LineAxis0, LinesAxis1, LinesAxis1XConstant,
LinesAxis1YConstant, LineAxis0Multi,
LinesAxis1Ragged)
LinesAxis1Ragged, LineAxis1Geometry)
from .reductions import any as any_rdn

validate_xy_or_geometry('Line', x, y, geometry)

if agg is None:
agg = any_rdn()

# Broadcast column specifications to handle cases where
# x is a list and y is a string or vice versa
orig_x, orig_y = x, y
x, y = _broadcast_column_specifications(x, y)
if geometry is not None:
from spatialpandas import GeoDataFrame
from spatialpandas.dask import DaskGeoDataFrame
if isinstance(source, DaskGeoDataFrame):
# Downselect partitions to those that may contain lines in viewport
x_range = self.x_range if self.x_range is not None else (None, None)
y_range = self.y_range if self.y_range is not None else (None, None)
source = source.cx_partitions[slice(*x_range), slice(*y_range)]
elif not isinstance(source, GeoDataFrame):
raise ValueError(
"source must be an instance of spatialpandas.GeoDataFrame or \n"
"spatialpandas.dask.DaskGeoDataFrame.\n"
" Received value of type {typ}".format(typ=type(source)))

glyph = LineAxis1Geometry(geometry)
else:
# Broadcast column specifications to handle cases where
# x is a list and y is a string or vice versa
orig_x, orig_y = x, y
x, y = _broadcast_column_specifications(x, y)

if axis == 0:
if (isinstance(x, (Number, string_types)) and
isinstance(y, (Number, string_types))):
glyph = LineAxis0(x, y)
elif (isinstance(x, (list, tuple)) and
isinstance(y, (list, tuple))):
glyph = LineAxis0Multi(tuple(x), tuple(y))
else:
raise ValueError("""
if axis == 0:
if (isinstance(x, (Number, string_types)) and
isinstance(y, (Number, string_types))):
glyph = LineAxis0(x, y)
elif (isinstance(x, (list, tuple)) and
isinstance(y, (list, tuple))):
glyph = LineAxis0Multi(tuple(x), tuple(y))
else:
raise ValueError("""
Invalid combination of x and y arguments to Canvas.line when axis=0.
Received:
x: {x}
y: {y}
See docstring for more information on valid usage""".format(
x=repr(orig_x), y=repr(orig_y)))
x=repr(orig_x), y=repr(orig_y)))

elif axis == 1:
if isinstance(x, (list, tuple)) and isinstance(y, (list, tuple)):
glyph = LinesAxis1(tuple(x), tuple(y))
elif (isinstance(x, np.ndarray) and
isinstance(y, (list, tuple))):
glyph = LinesAxis1XConstant(x, tuple(y))
elif (isinstance(x, (list, tuple)) and
isinstance(y, np.ndarray)):
glyph = LinesAxis1YConstant(tuple(x), y)
elif (isinstance(x, (Number, string_types)) and
isinstance(y, (Number, string_types))):
glyph = LinesAxis1Ragged(x, y)
else:
raise ValueError("""
elif axis == 1:
if isinstance(x, (list, tuple)) and isinstance(y, (list, tuple)):
glyph = LinesAxis1(tuple(x), tuple(y))
elif (isinstance(x, np.ndarray) and
isinstance(y, (list, tuple))):
glyph = LinesAxis1XConstant(x, tuple(y))
elif (isinstance(x, (list, tuple)) and
isinstance(y, np.ndarray)):
glyph = LinesAxis1YConstant(tuple(x), y)
elif (isinstance(x, (Number, string_types)) and
isinstance(y, (Number, string_types))):
glyph = LinesAxis1Ragged(x, y)
else:
raise ValueError("""
Invalid combination of x and y arguments to Canvas.line when axis=1.
Received:
x: {x}
y: {y}
See docstring for more information on valid usage""".format(
x=repr(orig_x), y=repr(orig_y)))
x=repr(orig_x), y=repr(orig_y)))

else:
raise ValueError("""
else:
raise ValueError("""
The axis argument to Canvas.line must be 0 or 1
Received: {axis}""".format(axis=axis))

Expand Down Expand Up @@ -575,6 +634,72 @@ def area(self, source, x, y, agg=None, axis=0, y_stack=None):

return bypixel(source, self, glyph, agg)

def polygons(self, source, geometry, agg=None):
"""Compute a reduction by pixel, mapping data to pixels as one or
more filled polygons.

Parameters
----------
source : xarray.DataArray or Dataset
The input datasource.
geometry : str
Column name of a PolygonsArray of the coordinates of each line.
agg : Reduction, optional
Reduction to compute. Default is ``any()``.

Returns
-------
data : xarray.DataArray

Examples
--------
>>> import datashader as ds # doctest: +SKIP
... import datashader.transfer_functions as tf
... from spatialpandas.geometry import PolygonArray
... from spatialpandas import GeoDataFrame
... import pandas as pd
...
... polygons = PolygonArray([
... # First Element
... [[0, 0, 1, 0, 2, 2, -1, 4, 0, 0], # Filled quadrilateral (CCW order)
... [0.5, 1, 1, 2, 1.5, 1.5, 0.5, 1], # Triangular hole (CW order)
... [0, 2, 0, 2.5, 0.5, 2.5, 0.5, 2, 0, 2], # Rectangular hole (CW order)
... [2.5, 3, 3.5, 3, 3.5, 4, 2.5, 3], # Filled triangle
... ],
...
... # Second Element
... [[3, 0, 3, 2, 4, 2, 4, 0, 3, 0], # Filled rectangle (CCW order)
... # Rectangular hole (CW order)
... [3.25, 0.25, 3.75, 0.25, 3.75, 1.75, 3.25, 1.75, 3.25, 0.25],
... ]
... ])
...
... df = GeoDataFrame({'polygons': polygons, 'v': range(len(polygons))})
...
... cvs = ds.Canvas()
... agg = cvs.polygons(df, geometry='polygons', agg=ds.sum('v'))
... tf.shade(agg)
"""
from .glyphs import PolygonGeom
from .reductions import any as any_rdn
from spatialpandas import GeoDataFrame
from spatialpandas.dask import DaskGeoDataFrame
if isinstance(source, DaskGeoDataFrame):
# Downselect partitions to those that may contain polygons in viewport
x_range = self.x_range if self.x_range is not None else (None, None)
y_range = self.y_range if self.y_range is not None else (None, None)
source = source.cx_partitions[slice(*x_range), slice(*y_range)]
elif not isinstance(source, GeoDataFrame):
raise ValueError(
"source must be an instance of spatialpandas.GeoDataFrame or \n"
"spatialpandas.dask.DaskGeoDataFrame.\n"
" Received value of type {typ}".format(typ=type(source)))

if agg is None:
agg = any_rdn()
glyph = PolygonGeom(geometry)
return bypixel(source, self, glyph, agg)

def quadmesh(self, source, x=None, y=None, agg=None):
"""Samples a recti- or curvi-linear quadmesh by canvas size and bounds.
Parameters
Expand Down
3 changes: 2 additions & 1 deletion datashader/data_libraries/pandas.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from datashader.core import bypixel
from datashader.compiler import compile_components
from datashader.glyphs.points import _PointLike
from datashader.glyphs.points import _PointLike, _GeometryLike
from datashader.glyphs.area import _AreaToLineLike
from datashader.utils import Dispatcher
from collections import OrderedDict
Expand All @@ -21,6 +21,7 @@ def pandas_pipeline(df, schema, canvas, glyph, summary):


@glyph_dispatch.register(_PointLike)
@glyph_dispatch.register(_GeometryLike)
@glyph_dispatch.register(_AreaToLineLike)
def default(glyph, source, schema, canvas, summary, cuda=False):
create, info, append, _, finalize = compile_components(summary, schema, glyph, cuda)
Expand Down
4 changes: 3 additions & 1 deletion datashader/glyphs/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from __future__ import absolute_import
from .points import Point # noqa (API import)
from .points import Point, MultiPointGeometry # noqa (API import)
from .line import ( # noqa (API import)
LineAxis0,
LineAxis0Multi,
LinesAxis1,
LinesAxis1XConstant,
LinesAxis1YConstant,
LinesAxis1Ragged,
LineAxis1Geometry,
)
from .area import ( # noqa (API import)
AreaToZeroAxis0,
Expand All @@ -23,6 +24,7 @@
AreaToLineAxis1Ragged,
)
from .trimesh import Triangles # noqa (API import)
from .polygon import PolygonGeom # noqa (API import)
from .quadmesh import ( # noqa (API import)
QuadMeshRectilinear, QuadMeshCurvialinear
)
Expand Down
Loading