Skip to content

Merge consecutive rasterizations #17159

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

Merged
merged 3 commits into from
Jul 14, 2020
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
15 changes: 15 additions & 0 deletions doc/api/api_changes/2020-04-26-merged-rasterizations.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
Consecutive rasterized draws now merged
---------------------------------------

Tracking of depth of raster draws has moved from
`.backend_mixed.MixedModeRenderer.start_rasterizing` and
`.backend_mixed.MixedModeRenderer.stop_rasterizing` into
`.artist.allow_rasterization`. This means the start and stop functions are
only called when the rasterization actually needs to be started and stopped.

The output of vector backends will change in the case that rasterized
elements are merged. This should not change the appearance of outputs.

The renders in 3rd party backends are now expected to have
``self._raster_depth`` and ``self._rasterizing`` initialized to ``0`` and
``False`` respectively.
13 changes: 13 additions & 0 deletions doc/users/next_whats_new/2020-04-26-merged-rasterizations.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Consecutive rasterized draws now merged
---------------------------------------

Elements of a vector output can be individually set to rasterized, using
the ``rasterized`` keyword, or `~.artist.Artist.set_rasterized()`. This can
be useful to reduce file sizes. For figures with multiple raster elements
they are now automatically merged into a smaller number of bitmaps where
this will not effect the visual output. For cases with many elements this
can result in significantly smaller file sizes.

To ensure this happens do not place vector elements between raster ones.

To inhibit this merging set ``Figure.suppressComposite`` to True.
32 changes: 31 additions & 1 deletion lib/matplotlib/artist.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,17 @@ def allow_rasterization(draw):
def draw_wrapper(artist, renderer, *args, **kwargs):
try:
if artist.get_rasterized():
renderer.start_rasterizing()
if renderer._raster_depth == 0 and not renderer._rasterizing:
Copy link
Member

Choose a reason for hiding this comment

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

I think it is ok to try and monkey-patch these attributes on if they don't exist.

It is a little bit dirty, but greatly reduces the chances we break anyone.

Copy link
Contributor

Choose a reason for hiding this comment

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

This doesn't really work if the renderer class has no __dict__ (given that renderer classes can easily be defined in extension modules this is not just a hypothetical).
I think saying "you have to inherit from RendererBase" (or are responsible for tracking changes upstream) is the reasonable way to go.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The new requirement for those attributes is noted in the API changes. Should I add the text about inheriting from RenderBase there? or somewhere else? I would think that is it implicit that if a 3rd party replaces a class without inheriting from the original that they need to track upstream changes.

In #17159 (comment) you say that mplcairo can handle the change.

renderer.start_rasterizing()
renderer._rasterizing = True
renderer._raster_depth += 1
else:
if renderer._raster_depth == 0 and renderer._rasterizing:
# Only stop when we are not in a rasterized parent
# and something has be rasterized since last stop
renderer.stop_rasterizing()
renderer._rasterizing = False

if artist.get_agg_filter() is not None:
renderer.start_filter()

Expand All @@ -43,12 +53,32 @@ def draw_wrapper(artist, renderer, *args, **kwargs):
if artist.get_agg_filter() is not None:
renderer.stop_filter(artist.get_agg_filter())
if artist.get_rasterized():
renderer._raster_depth -= 1
if (renderer._rasterizing and artist.figure and
artist.figure.suppressComposite):
# restart rasterizing to prevent merging
renderer.stop_rasterizing()
renderer.start_rasterizing()

draw_wrapper._supports_rasterization = True
return draw_wrapper


def _finalize_rasterization(draw):
"""
Decorator for Artist.draw method. Needed on the outermost artist, i.e.
Figure, to finish up if the render is still in rasterized mode.
"""
@wraps(draw)
def draw_wrapper(artist, renderer, *args, **kwargs):
result = draw(artist, renderer, *args, **kwargs)
if renderer._rasterizing:
renderer.stop_rasterizing()
renderer._rasterizing = False
return result
return draw_wrapper


def _stale_axes_callback(self, val):
if self.axes:
self.axes.stale = val
Expand Down
2 changes: 2 additions & 0 deletions lib/matplotlib/backend_bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ def __init__(self):
super().__init__()
self._texmanager = None
self._text2path = textpath.TextToPath()
self._raster_depth = 0
self._rasterizing = False

def open_group(self, s, gid=None):
"""
Expand Down
62 changes: 27 additions & 35 deletions lib/matplotlib/backends/backend_mixed.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ def __init__(self, figure, width, height, dpi, vector_renderer,
self._vector_renderer = vector_renderer

self._raster_renderer = None
self._rasterizing = 0

# A reference to the figure is needed as we need to change
# the figure dpi before and after the rasterization. Although
Expand Down Expand Up @@ -84,47 +83,40 @@ def start_rasterizing(self):
r = process_figure_for_rasterizing(self.figure,
self._bbox_inches_restore)
self._bbox_inches_restore = r
if self._rasterizing == 0:
self._raster_renderer = self._raster_renderer_class(
self._width*self.dpi, self._height*self.dpi, self.dpi)
self._renderer = self._raster_renderer
self._rasterizing += 1

self._raster_renderer = self._raster_renderer_class(
self._width*self.dpi, self._height*self.dpi, self.dpi)
self._renderer = self._raster_renderer

def stop_rasterizing(self):
"""
Exit "raster" mode. All of the drawing that was done since
the last `start_rasterizing` call will be copied to the
vector backend by calling draw_image.

If `start_rasterizing` has been called multiple times,
`stop_rasterizing` must be called the same number of times before
"raster" mode is exited.
"""
self._rasterizing -= 1
if self._rasterizing == 0:
self._renderer = self._vector_renderer

height = self._height * self.dpi
buffer, bounds = self._raster_renderer.tostring_rgba_minimized()
l, b, w, h = bounds
if w > 0 and h > 0:
image = np.frombuffer(buffer, dtype=np.uint8)
image = image.reshape((h, w, 4))
image = image[::-1]
gc = self._renderer.new_gc()
# TODO: If the mixedmode resolution differs from the figure's
# dpi, the image must be scaled (dpi->_figdpi). Not all
# backends support this.
self._renderer.draw_image(
gc,
l * self._figdpi / self.dpi,
(height-b-h) * self._figdpi / self.dpi,
image)
self._raster_renderer = None
self._rasterizing = False

# restore the figure dpi.
self.figure.set_dpi(self._figdpi)

self._renderer = self._vector_renderer

height = self._height * self.dpi
buffer, bounds = self._raster_renderer.tostring_rgba_minimized()
l, b, w, h = bounds
if w > 0 and h > 0:
image = np.frombuffer(buffer, dtype=np.uint8)
image = image.reshape((h, w, 4))
image = image[::-1]
gc = self._renderer.new_gc()
# TODO: If the mixedmode resolution differs from the figure's
# dpi, the image must be scaled (dpi->_figdpi). Not all
# backends support this.
self._renderer.draw_image(
gc,
l * self._figdpi / self.dpi,
(height-b-h) * self._figdpi / self.dpi,
image)
self._raster_renderer = None

# restore the figure dpi.
self.figure.set_dpi(self._figdpi)

if self._bbox_inches_restore: # when tight bbox is used
r = process_figure_for_rasterizing(self.figure,
Expand Down
4 changes: 3 additions & 1 deletion lib/matplotlib/figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
from matplotlib import __version__ as _mpl_version

import matplotlib.artist as martist
from matplotlib.artist import Artist, allow_rasterization
from matplotlib.artist import (
Artist, allow_rasterization, _finalize_rasterization)
from matplotlib.backend_bases import (
FigureCanvasBase, NonGuiException, MouseButton)
import matplotlib.cbook as cbook
Expand Down Expand Up @@ -1689,6 +1690,7 @@ def clear(self, keep_observers=False):
"""Clear the figure -- synonym for `clf`."""
self.clf(keep_observers=keep_observers)

@_finalize_rasterization
@allow_rasterization
def draw(self, renderer):
# docstring inherited
Expand Down
100 changes: 99 additions & 1 deletion lib/matplotlib/tests/test_backend_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from matplotlib import dviread
from matplotlib.figure import Figure
import matplotlib.pyplot as plt
from matplotlib.testing.decorators import image_comparison
from matplotlib.testing.decorators import image_comparison, check_figures_equal


needs_usetex = pytest.mark.skipif(
Expand Down Expand Up @@ -94,6 +94,104 @@ def test_bold_font_output_with_none_fonttype():
ax.set_title('bold-title', fontweight='bold')


@check_figures_equal(tol=20)
def test_rasterized(fig_test, fig_ref):
t = np.arange(0, 100) * (2.3)
x = np.cos(t)
y = np.sin(t)

ax_ref = fig_ref.subplots()
ax_ref.plot(x, y, "-", c="r", lw=10)
ax_ref.plot(x+1, y, "-", c="b", lw=10)

ax_test = fig_test.subplots()
ax_test.plot(x, y, "-", c="r", lw=10, rasterized=True)
ax_test.plot(x+1, y, "-", c="b", lw=10, rasterized=True)


@check_figures_equal()
def test_rasterized_ordering(fig_test, fig_ref):
t = np.arange(0, 100) * (2.3)
x = np.cos(t)
y = np.sin(t)

ax_ref = fig_ref.subplots()
ax_ref.set_xlim(0, 3)
ax_ref.set_ylim(-1.1, 1.1)
ax_ref.plot(x, y, "-", c="r", lw=10, rasterized=True)
ax_ref.plot(x+1, y, "-", c="b", lw=10, rasterized=False)
ax_ref.plot(x+2, y, "-", c="g", lw=10, rasterized=True)
ax_ref.plot(x+3, y, "-", c="m", lw=10, rasterized=True)

ax_test = fig_test.subplots()
ax_test.set_xlim(0, 3)
ax_test.set_ylim(-1.1, 1.1)
ax_test.plot(x, y, "-", c="r", lw=10, rasterized=True, zorder=1.1)
ax_test.plot(x+2, y, "-", c="g", lw=10, rasterized=True, zorder=1.3)
ax_test.plot(x+3, y, "-", c="m", lw=10, rasterized=True, zorder=1.4)
ax_test.plot(x+1, y, "-", c="b", lw=10, rasterized=False, zorder=1.2)


def test_count_bitmaps():
def count_tag(fig, tag):
fd = BytesIO()
fig.savefig(fd, format='svg')
fd.seek(0)
buf = fd.read().decode()
fd.close()
open("test.svg", "w").write(buf)
return buf.count("<%s" % tag)

# No rasterized elements
fig1 = plt.figure()
ax1 = fig1.add_subplot(1, 1, 1)
ax1.set_axis_off()
for n in range(5):
ax1.plot([0, 20], [0, n], "b-", rasterized=False)
assert count_tag(fig1, "image") == 0
assert count_tag(fig1, "path") == 6 # axis patch plus lines

# rasterized can be merged
fig2 = plt.figure()
ax2 = fig2.add_subplot(1, 1, 1)
ax2.set_axis_off()
for n in range(5):
ax2.plot([0, 20], [0, n], "b-", rasterized=True)
assert count_tag(fig2, "image") == 1
assert count_tag(fig2, "path") == 1 # axis patch

# rasterized can't be merged without effecting draw order
fig3 = plt.figure()
ax3 = fig3.add_subplot(1, 1, 1)
ax3.set_axis_off()
for n in range(5):
ax3.plot([0, 20], [n, 0], "b-", rasterized=False)
ax3.plot([0, 20], [0, n], "b-", rasterized=True)
assert count_tag(fig3, "image") == 5
assert count_tag(fig3, "path") == 6

# rasterized whole axes
fig4 = plt.figure()
ax4 = fig4.add_subplot(1, 1, 1)
ax4.set_axis_off()
ax4.set_rasterized(True)
for n in range(5):
ax4.plot([0, 20], [n, 0], "b-", rasterized=False)
ax4.plot([0, 20], [0, n], "b-", rasterized=True)
assert count_tag(fig4, "image") == 1
assert count_tag(fig4, "path") == 1

# rasterized can be merged, but inhibited by suppressComposite
fig5 = plt.figure()
fig5.suppressComposite = True
ax5 = fig5.add_subplot(1, 1, 1)
ax5.set_axis_off()
for n in range(5):
ax5.plot([0, 20], [0, n], "b-", rasterized=True)
assert count_tag(fig5, "image") == 5
assert count_tag(fig5, "path") == 1 # axis patch


@needs_usetex
def test_missing_psfont(monkeypatch):
"""An error is raised if a TeX font lacks a Type-1 equivalent"""
Expand Down