Skip to content

Add new downsample method for lines #7632

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 16 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/plot_downsample.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Downsample line plots
---------------------

A ``downsample`` parameter now exists for the method :func:`plot`. This
allows line plots to be intelligently downsampled so that rendering and
interaction is faster. The downsampling algorithm will render an image
very similar to a non-downsampled image.
30 changes: 30 additions & 0 deletions examples/pyplots/pyplot_downsample.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""
Demo of downsampling line plots.

This method of downsampling will greatly speed up interaction
and rendering time, without changing the actual rasterization too much.
Try interacting with the two figures (panning, zooming, etc.) to see
the difference.

"""
import numpy as np
Copy link
Member

Choose a reason for hiding this comment

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

Can you add a sphinx-gallery like docstring to explain why this example has been added to the gallery?

import matplotlib.pyplot as plt

# Fixing random state for reproducibility
np.random.seed(8675309)

# make up some data
y = np.random.normal(loc=0.5, scale=0.4, size=1000000)
x = np.arange(len(y))

# plot without downsampling
plt.figure(1)
plt.plot(x, y)

# plot with downsampling
plt.figure(2)
plt.plot(x, y, downsample=True)

plt.show()

# interact with both figures to compare snapiness
113 changes: 113 additions & 0 deletions lib/matplotlib/lines.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ def __init__(self, xdata, ydata,
pickradius=5,
drawstyle=None,
markevery=None,
downsample=None,
**kwargs
):
"""
Expand Down Expand Up @@ -408,6 +409,8 @@ def __init__(self, xdata, ydata,
self.set_antialiased(antialiased)
self.set_markersize(markersize)

self._downsample = downsample

self._markeredgecolor = None
self._markeredgewidth = None
self._markerfacecolor = None
Expand Down Expand Up @@ -583,6 +586,10 @@ def set_markevery(self, every):
markers is always determined from the display-coordinates
axes-bounding-box-diagonal regardless of the actual axes data limits.

See also
--------
downsample : for downsampling the line segments that are plotted.

"""
if self._markevery != every:
self.stale = True
Expand All @@ -592,6 +599,59 @@ def get_markevery(self):
"""return the markevery setting"""
return self._markevery

@property
def downsample(self):
"""Set the downsample property to subsample the plotted line segments.
Copy link
Member

Choose a reason for hiding this comment

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

The current docstring is a mixture of our old and new style of docstring. Can you please stick to the new style? (I'm adding a couple notes to explain what I mean).


If True, then only a subset of line segments will be plotted. The
subset is carefully chosen so that the rendering of the downsampled
plot looks very similar to the rending of the non-downsampled plot. In
fact, if there is no anti-aliasing and plotting uses a solid line only,
then the renderings will be identical.

Recommended *only* when plotting with a solid line (e.g. no dashes,
no markers).

Parameters
----------
downsample : True | False
Whether or not to downsample.

- downsample=True, a downsampled set of points will be plotted.
- downsample=False, every point will be plotted.

Notes
-----
If the x-values of the line are monotonic, then the downsampling
algorithm will plot at most 4 times the width of the parent
:class:`matplotlib.axes.Axes` in pixels of vertices. If the x-values
are not monotonic, the plot will still render correctly, but you are
not guaranteed any faster rendering.

If plotting with a non-solid line format (e.g. '--'), then
the downsampled plot could render quite differently from the
non-downsampled plot.

Markers will not be downsampled.

If the line is anti-aliased, then the downsampled plot will not
render *exactly* the same as a non-dowsampled plot,
but will be very similar.

See also
--------
markevery : for downsampling the markers that are plotted.

"""
return self._downsample

@downsample.setter
def downsample(self, downsample):
"""Sets the downsample property."""
if self._downsample != downsample:
self.stale = True
self._downsample = downsample

def set_picker(self, p):
"""Sets the event picker details for the line.

Expand Down Expand Up @@ -747,6 +807,57 @@ def _is_sorted(self, x):
# We don't handle the monotonically decreasing case.
return _path.is_sorted(x)

def _downsample_path(self, tpath, affine):
"""
Helper function to compute the downsampled path.
"""
# pull out the two bits of data we want from the path
codes, verts = tpath.codes, tpath.vertices

def _slice_or_none(in_v, slc):
"""
Helper function to cope with `codes` being an ndarray or `None`
"""
if in_v is None:
return None
return in_v[slc]

# Convert vertices from data space to pixel space.
# Any non-affine axis transformation has already been applied
# to tpath, so we just need to apply affine part.
verts_trans = affine.transform_path(tpath).vertices

# Find where the pixel column of the x data changes
split_indices = np.diff(np.floor(verts_trans[:, 0]).astype(int)) != 0
split_indices = np.where(split_indices)[0] + 1

Copy link
Member

Choose a reason for hiding this comment

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

Maybe shorten the code and reduce the number of operations inside the loop (untested!):

for i, y_pixel_col in enumerate(np.split(verts_trans[:, 1], split_indices)):
    keep_inds[i, 1:3] = np.argmin(y_pixel_col), np.argmax(y_pixel_col)
starts = np.hstack((0, split_indices))
ends = np.hstack((split_indices, verts_trans.shape[0]))
keep_inds[:, :3] += starts[:, np.newaxis]
keep_inds[:, 3] = ends - 1

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea. Done. Your code worked without modification for me.

# Don't waste time downsampling if it won't give us any benefit
# 4.0 was chosen somewhat arbitrarily.
if split_indices.size >= verts_trans.shape[0] / 4.0:
return tpath

keep_inds = np.zeros((split_indices.size + 1, 4), dtype=int)
for i, y_pixel_col in enumerate(np.split(verts_trans[:, 1],
split_indices)):
try:
keep_inds[i, 1:3] = (np.nanargmin(y_pixel_col),
np.nanargmax(y_pixel_col))
except ValueError:
# np.nanarg* raises a ValueError if all elements are NaN.
# If either np.nanargmin or np.nanargmax raise this error,
# then all y_pixel_col is NaN. In this case, the argmin
# and argmax are both undefined, just keep the first value.
keep_inds[i, 1:3] = 0

starts = np.hstack((0, split_indices))
ends = np.hstack((split_indices, verts_trans.shape[0]))
keep_inds[:, :3] += starts[:, np.newaxis]
keep_inds[:, 3] = ends - 1

keep_inds = keep_inds.flatten()

return Path(verts[keep_inds], _slice_or_none(codes, keep_inds))

@allow_rasterization
def draw(self, renderer):
"""draw the Line with `renderer` unless visibility is False"""
Expand Down Expand Up @@ -775,6 +886,8 @@ def draw(self, renderer):
if funcname != '_draw_nothing':
tpath, affine = transf_path.get_transformed_path_and_affine()
if len(tpath.vertices):
if self._downsample:
tpath = self._downsample_path(tpath, affine)
line_func = getattr(self, funcname)
gc = renderer.new_gc()
self._set_gc_clip(gc)
Expand Down
50 changes: 50 additions & 0 deletions lib/matplotlib/tests/test_lines.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,5 +191,55 @@ def test_nan_is_sorted():
assert_true(not line._is_sorted([3, 5] + [np.nan] * 100 + [0, 2]))


@cleanup
def test_downsample_lines():
x = np.hstack((np.zeros(99), np.ones(99)))
y = np.hstack([2.0, 8.0, 3 * np.ones(95), 1.0, 5.0,
5.0, 5.5, 6 * np.ones(95), 8.0, 7.0])

line = mlines.Line2D(x, y, downsample=True)

_, ax = plt.subplots()

ax.add_line(line)

transf_path = line._get_transformed_path()
tpath, affine = transf_path.get_transformed_path_and_affine()
down_verts = line._downsample_path(tpath, affine).vertices

expected_down_verts = np.array([
[0.0, 2.0],
[0.0, 1.0],
[0.0, 8.0],
[0.0, 5.0],
[1.0, 5.0],
[1.0, 5.0],
[1.0, 8.0],
[1.0, 7.0]
])
assert_true(np.all(down_verts == expected_down_verts))


@cleanup
def test_downsample_nan_lines():
# Creates x and y data, add some NaN values
N = 10**5
x = np.linspace(0,1,N)
y = np.random.normal(size=N)
x[10000:30000] = np.nan
y[20000:40000] = np.nan

line = mlines.Line2D(x, y, downsample=True)

fig, ax = plt.subplots()

ax.add_line(line)

fig.canvas.draw()

# Nothing to assert here, we are just making sure NaN values
# do not raise any errors.


if __name__ == '__main__':
nose.runmodule(argv=['-s', '--with-doctest'], exit=False)