Skip to content

Rotate markers in Scatter plot #2478

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

Closed
wants to merge 7 commits into from
Closed
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
7 changes: 7 additions & 0 deletions doc/users/whats_new.rst
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,13 @@ values to the interval [0,1] with power-law scaling with the exponent
provided by the constructor's `gamma` argument. Power law normalization
can be useful for, e.g., emphasizing small populations in a histogram.

Add Rotate marker capabilities in scatter plot
``````````````````````````````````````````````

mgoacolou add `angles` parameter to :func:`~matplotlib.pyplot.scatter`.
:ref:`~examples/pylab_examples/scatter_rotate_symbol.py`


Fully customizable boxplots
```````````````````````````
Paul Hobson overhauled the :func:`~matplotlib.pyplot.boxplot` method such
Expand Down
17 changes: 17 additions & 0 deletions examples/pylab_examples/scatter_rotate_symbol.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.markers import TICKRIGHT

nbpts = 50
rx, ry = 3., 1.
area = rx * ry * np.pi
angles = np.linspace(0., 360., nbpts)

x, y, sizes, colors = np.random.rand(4, nbpts)
sizes *= 2000.

plt.scatter(x, y, sizes, colors, marker="*", angles=angles, zorder=2)
plt.scatter(x, y, 2.5*sizes, colors, marker=TICKRIGHT, angles=angles, zorder=2)

plt.show()

9 changes: 7 additions & 2 deletions lib/matplotlib/axes/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3758,6 +3758,9 @@ def scatter(self, x, y, s=None, c=None, marker='o', cmap=None, norm=None,
an instance of the class or the text shorthand for a particular
marker.

angles : scalar or array_like, shape (n, ), optional, default: 0
degrees counter clock-wise from X axis

cmap : `~matplotlib.colors.Colormap`, optional, default: None
A `~matplotlib.colors.Colormap` instance or registered name.
`cmap` is only used if `c` is an array of floats. If None,
Expand Down Expand Up @@ -3861,13 +3864,15 @@ def scatter(self, x, y, s=None, c=None, marker='o', cmap=None, norm=None,
if x.size != y.size:
raise ValueError("x and y must be the same size")

angles = np.ma.ravel(angles) # This doesn't have to match x, y in size.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is one character too long for pep8.


if s is None:
if rcParams['_internal.classic_mode']:
s = 20
else:
s = rcParams['lines.markersize'] ** 2.0

s = np.ma.ravel(s) # This doesn't have to match x, y in size.
x, y, s, c, angles = cbook.delete_masked_points(x, y, s, c, angles)

# After this block, c_array will be None unless
# c is an array for mapping. The potential ambiguity
Expand Down Expand Up @@ -3913,7 +3918,7 @@ def scatter(self, x, y, s=None, c=None, marker='o', cmap=None, norm=None,
offsets = np.dstack((x, y))

collection = mcoll.PathCollection(
(path,), scales,
(path,), scales, angles,
facecolors=colors,
edgecolors=edgecolors,
linewidths=linewidths,
Expand Down
117 changes: 107 additions & 10 deletions lib/matplotlib/collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,10 @@ class _CollectionWithSizes(Collection):
"""
_factor = 1.0

def __init__(self, *arg, **kw):
super(_CollectionWithSizes, self).__init__(*arg, **kw)
self._sizes = np.array([])

def get_sizes(self):
"""
Returns the sizes of the elements in the collection. The
Expand All @@ -790,6 +794,18 @@ def get_sizes(self):
"""
return self._sizes

def __unset_sizes(self):
if len(self._sizes) == 0:
return
scale = np.sqrt(self._sizes) * self.__dpi / 72.0
s = np.zeros((len(self._sizes),3,3))
s[:, 0, 0] = scale
s[:, 1, 1] = scale
s[:, 2, 2] = 1.0
for i in xrange(self._transforms.shape[0]):
isc = np.linalg.inv(s[i%len(self._sizes),:,:])
self._transforms[i,:,:] = np.dot(isc, self._transforms[i,:,:])

def set_sizes(self, sizes, dpi=72.0):
"""
Set the sizes of each member of the collection.
Expand All @@ -808,44 +824,125 @@ def set_sizes(self, sizes, dpi=72.0):
self._transforms = np.empty((0, 3, 3))
else:
self._sizes = np.asarray(sizes)
self._transforms = np.zeros((len(self._sizes), 3, 3))
scale = np.sqrt(self._sizes) * dpi / 72.0 * self._factor
self._transforms[:, 0, 0] = scale
self._transforms[:, 1, 1] = scale
self._transforms[:, 2, 2] = 1.0
if self._transforms is None or \
(isinstance(self._transforms, list) and not self._transforms) or \
(isinstance(self._transforms, np.ndarray) and not self._transforms.any()):
self._transforms = np.zeros((len(self._sizes), 3, 3))
self._transforms[:, 0, 0] = scale
self._transforms[:, 1, 1] = scale
self._transforms[:, 2, 2] = 1.0
self.stale = True
s = np.zeros((len(self._sizes),3,3))
s[:, 0, 0] = scale
s[:, 1, 1] = scale
s[:, 2, 2] = 1.0
if self._transforms.shape[0] < len(self._sizes):
# resize transforms to feat at least the sizes length
self._transforms = np.resize(self._transforms,(len(self._sizes),3,3))
for i in xrange(self._transforms.shape[0]):
self._transforms[i,:,:] = np.dot(s[i%len(self._sizes),:,:], self._transforms[i,:,:])

@allow_rasterization
def draw(self, renderer):
self.set_sizes(self._sizes, self.figure.dpi)
Collection.draw(self, renderer)
super(_CollectionWithSizes, self).draw(renderer)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mdboom Is there a project view on using super? (my understanding was that it is like const correctness in c++ in that it is an all or nothing thing).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super() use, in pyhton, is like using virtual member function call mechanism in C++.
In this case draw method will be call in PathCollection, _CollectionWithSizes, _CollectionWithAngles and Collection. Without only _CollectionWithSizes and Collection.draw() will be called

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding was that was the cascades only happen correctly if every step along the way makes use of super, not just the last one and I do not recall seeing any other use of super in mpl.

It was also my understanding that the virtual keyword in c++ was to ensure that if you call a function which has been overridden in a child class on a child class object which the complier sees as a base type (via polymorphism), the child-class's version of the function was called, not the base class's. I do not think it ensures that the function is called on all parent classes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, the cascades only happen correctly if every step along the way makes use of super. the power of super is that the class don't have to know the full hierarchy of class. In case of refactoring, this makes less bugs.
For me super must always be used in __init__ and all method using polymorphism like draw

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, the code base does not currently make heavy use of super (run grep --include='*py' 'super(' lib/matplotlib -R in the repo root). I don't disagree it is useful, but if you want to put it in, then we need to make sure the entire Artist class hierarchy gets it (it looks like there are 76 classes involved). which is probably more than we want to do with out a MEP.


class _CollectionWithAngles(Collection):
"""
Base class for collections that have an array of angles.
"""
def __init__(self, *arg, **kw):
super(_CollectionWithAngles, self).__init__(*arg, **kw)
self._angles = np.array([])

def get_angles(self):
return self._angles

def __unset_angles(self):
if len(self._angles) == 0:
return
rot = np.deg2rad(-self._angles)
rot_c = np.cos(rot)
rot_s = np.sin(rot)
r = np.zeros((len(self._angles), 3, 3))
r[:, 0, 0] = rot_c
r[:, 0, 1] = -rot_s
r[:, 1, 1] = rot_c
r[:, 1, 0] = rot_s
r[:, 2, 2] = 1.0
for i in xrange(self._transforms.shape[0]):
irt = np.linalg.inv(r[i%len(self._angles),:,:])
self._transforms[i,:,:] = np.dot(irt, self._transforms[i,:,:])

def set_angles(self, angles):
self.__unset_angles()
if angles is None:
self._angles = np.array([])
self._transforms = np.empty((0, 3, 3))
else:
self._angles = np.asarray(angles)
rot = np.deg2rad(-self._angles)
rot_c = np.cos(rot)
rot_s = np.sin(rot)
if self._transforms is None or \
(isinstance(self._transforms, list) and not self._transforms) or \
(isinstance(self._transforms, np.ndarray) and not self._transforms.any()):
self._transforms = np.zeros((len(self._angles), 3, 3))
self._transforms[:, 0, 0] = rot_c
self._transforms[:, 0, 1] = -rot_s
self._transforms[:, 1, 1] = rot_c
self._transforms[:, 1, 0] = rot_s
self._transforms[:, 2, 2] = 1.0
else:
r = np.zeros((len(self._angles), 3, 3))
r[:, 0, 0] = rot_c
r[:, 0, 1] = -rot_s
r[:, 1, 1] = rot_c
r[:, 1, 0] = rot_s
r[:, 2, 2] = 1.0
if self._transforms.shape[0] < len(self._angles):
# resize transforms to feat at least the angles length
self._transforms = np.resize(self._transforms,(len(self._angles),3,3))
for i in xrange(self._transforms.shape[0]):
self._transforms[i,:,:] = np.dot(r[i%len(self._angles),:,:], self._transforms[i,:,:])

class PathCollection(_CollectionWithSizes):
def draw(self, renderer):
super(_CollectionWithAngles, self).draw(renderer)


class PathCollection(_CollectionWithSizes, _CollectionWithAngles):
"""
This is the most basic :class:`Collection` subclass.
"""
@docstring.dedent_interpd
def __init__(self, paths, sizes=None, **kwargs):
def __init__(self, paths, sizes=None, angles=None, **kwargs):
"""
*paths* is a sequence of :class:`matplotlib.path.Path`
instances.

%(Collection)s
"""

Collection.__init__(self, **kwargs)
super(PathCollection, self).__init__(**kwargs)
self.set_paths(paths)
self.set_sizes(sizes)
self.stale = True
self.set_rotations(angles)

def set_paths(self, paths):
"""
update the paths sequence
"""
self._paths = paths
self.stale = True

def get_paths(self):
"""
return the paths sequence
"""
return self._paths

def draw(self, renderer):
super(PathCollection, self).draw(renderer)

class PolyCollection(_CollectionWithSizes):
@docstring.dedent_interpd
Expand Down