Skip to content

Move pixel ratio handling into FigureCanvasBase #19126

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 1 commit into from
Apr 8, 2021
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
70 changes: 67 additions & 3 deletions lib/matplotlib/backend_bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -1729,6 +1729,9 @@ def __init__(self, figure=None):
self.mouse_grabber = None # the axes currently grabbing mouse
self.toolbar = None # NavigationToolbar2 will set me
self._is_idle_drawing = False
# We don't want to scale up the figure DPI more than once.
figure._original_dpi = figure.dpi
self._device_pixel_ratio = 1

callbacks = property(lambda self: self.figure._canvas_callbacks)
button_pick_id = property(lambda self: self.figure._button_pick_id)
Expand Down Expand Up @@ -2054,12 +2057,73 @@ def draw_idle(self, *args, **kwargs):
with self._idle_draw_cntx():
self.draw(*args, **kwargs)

@property
def device_pixel_ratio(self):
"""
The ratio of physical to logical pixels used for the canvas on screen.

By default, this is 1, meaning physical and logical pixels are the same
size. Subclasses that support High DPI screens may set this property to
indicate that said ratio is different. All Matplotlib interaction,
unless working directly with the canvas, remains in logical pixels.

"""
return self._device_pixel_ratio

def _set_device_pixel_ratio(self, ratio):
"""
Set the ratio of physical to logical pixels used for the canvas.

Subclasses that support High DPI screens can set this property to
indicate that said ratio is different. The canvas itself will be
created at the physical size, while the client side will use the
logical size. Thus the DPI of the Figure will change to be scaled by
this ratio. Implementations that support High DPI screens should use
physical pixels for events so that transforms back to Axes space are
correct.

By default, this is 1, meaning physical and logical pixels are the same
size.

Parameters
----------
ratio : float
The ratio of logical to physical pixels used for the canvas.

Returns
-------
bool
Whether the ratio has changed. Backends may interpret this as a
signal to resize the window, repaint the canvas, or change any
other relevant properties.
"""
if self._device_pixel_ratio == ratio:
return False
# In cases with mixed resolution displays, we need to be careful if the
# device pixel ratio changes - in this case we need to resize the
# canvas accordingly. Some backends provide events that indicate a
# change in DPI, but those that don't will update this before drawing.
dpi = ratio * self.figure._original_dpi
self.figure._set_dpi(dpi, forward=False)
self._device_pixel_ratio = ratio
return True

def get_width_height(self):
"""
Return the figure width and height in points or pixels
(depending on the backend), truncated to integers.
Return the figure width and height in integral points or pixels.

When the figure is used on High DPI screens (and the backend supports
it), the truncation to integers occurs after scaling by the device
pixel ratio.

Returns
-------
width, height : int
The size of the figure, in points or pixels, depending on the
backend.
"""
return int(self.figure.bbox.width), int(self.figure.bbox.height)
return tuple(int(size / self.device_pixel_ratio)
for size in self.figure.bbox.max)

@classmethod
def get_supported_filetypes(cls):
Expand Down
45 changes: 8 additions & 37 deletions lib/matplotlib/backends/backend_qt5.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,15 +213,6 @@ def __init__(self, figure=None):
_create_qApp()
super().__init__(figure=figure)

# We don't want to scale up the figure DPI more than once.
# Note, we don't handle a signal for changing DPI yet.
self.figure._original_dpi = self.figure.dpi
self._update_figure_dpi()
# In cases with mixed resolution displays, we need to be careful if the
# dpi_ratio changes - in this case we need to resize the canvas
# accordingly.
self._dpi_ratio_prev = self._dpi_ratio

self._draw_pending = False
self._is_drawing = False
self._draw_rect_callback = lambda painter: None
Expand All @@ -233,28 +224,13 @@ def __init__(self, figure=None):
palette = QtGui.QPalette(QtCore.Qt.white)
self.setPalette(palette)

def _update_figure_dpi(self):
dpi = self._dpi_ratio * self.figure._original_dpi
self.figure._set_dpi(dpi, forward=False)

@property
def _dpi_ratio(self):
return _devicePixelRatioF(self)

def _update_pixel_ratio(self):
# We need to be careful in cases with mixed resolution displays if
# dpi_ratio changes.
if self._dpi_ratio != self._dpi_ratio_prev:
# We need to update the figure DPI.
self._update_figure_dpi()
self._dpi_ratio_prev = self._dpi_ratio
if self._set_device_pixel_ratio(_devicePixelRatioF(self)):
# The easiest way to resize the canvas is to emit a resizeEvent
# since we implement all the logic for resizing the canvas for
# that event.
event = QtGui.QResizeEvent(self.size(), self.size())
self.resizeEvent(event)
# resizeEvent triggers a paintEvent itself, so we exit this one
# (after making sure that the event is immediately handled).

def _update_screen(self, screen):
# Handler for changes to a window's attached screen.
Expand All @@ -270,10 +246,6 @@ def showEvent(self, event):
window.screenChanged.connect(self._update_screen)
self._update_screen(window.screen())

def get_width_height(self):
w, h = FigureCanvasBase.get_width_height(self)
return int(w / self._dpi_ratio), int(h / self._dpi_ratio)

def enterEvent(self, event):
try:
x, y = self.mouseEventCoords(event.pos())
Expand All @@ -296,11 +268,10 @@ def mouseEventCoords(self, pos):

Also, the origin is different and needs to be corrected.
"""
dpi_ratio = self._dpi_ratio
x = pos.x()
# flip y so y=0 is bottom of canvas
y = self.figure.bbox.height / dpi_ratio - pos.y()
return x * dpi_ratio, y * dpi_ratio
y = self.figure.bbox.height / self.device_pixel_ratio - pos.y()
return x * self.device_pixel_ratio, y * self.device_pixel_ratio

def mousePressEvent(self, event):
x, y = self.mouseEventCoords(event.pos())
Expand Down Expand Up @@ -361,8 +332,8 @@ def keyReleaseEvent(self, event):
FigureCanvasBase.key_release_event(self, key, guiEvent=event)

def resizeEvent(self, event):
w = event.size().width() * self._dpi_ratio
h = event.size().height() * self._dpi_ratio
w = event.size().width() * self.device_pixel_ratio
h = event.size().height() * self.device_pixel_ratio
dpival = self.figure.dpi
winch = w / dpival
hinch = h / dpival
Expand Down Expand Up @@ -460,7 +431,7 @@ def blit(self, bbox=None):
if bbox is None and self.figure:
bbox = self.figure.bbox # Blit the entire canvas if bbox is None.
# repaint uses logical pixels, not physical pixels like the renderer.
l, b, w, h = [int(pt / self._dpi_ratio) for pt in bbox.bounds]
l, b, w, h = [int(pt / self.device_pixel_ratio) for pt in bbox.bounds]
t = b + h
self.repaint(l, self.rect().height() - t, w, h)

Expand All @@ -481,11 +452,11 @@ def drawRectangle(self, rect):
# Draw the zoom rectangle to the QPainter. _draw_rect_callback needs
# to be called at the end of paintEvent.
if rect is not None:
x0, y0, w, h = [int(pt / self._dpi_ratio) for pt in rect]
x0, y0, w, h = [int(pt / self.device_pixel_ratio) for pt in rect]
x1 = x0 + w
y1 = y0 + h
def _draw_rect_callback(painter):
pen = QtGui.QPen(QtCore.Qt.black, 1 / self._dpi_ratio)
pen = QtGui.QPen(QtCore.Qt.black, 1 / self.device_pixel_ratio)
pen.setDashPattern([3, 3])
for color, offset in [
(QtCore.Qt.black, 0), (QtCore.Qt.white, 3)]:
Expand Down
6 changes: 3 additions & 3 deletions lib/matplotlib/backends/backend_qt5agg.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ def paintEvent(self, event):
# scale rect dimensions using the screen dpi ratio to get
# correct values for the Figure coordinates (rather than
# QT5's coords)
width = rect.width() * self._dpi_ratio
height = rect.height() * self._dpi_ratio
width = rect.width() * self.device_pixel_ratio
height = rect.height() * self.device_pixel_ratio
left, top = self.mouseEventCoords(rect.topLeft())
# shift the "top" by the height of the image to get the
# correct corner for our coordinate system
Expand All @@ -61,7 +61,7 @@ def paintEvent(self, event):

qimage = QtGui.QImage(buf, buf.shape[1], buf.shape[0],
QtGui.QImage.Format_ARGB32_Premultiplied)
_setDevicePixelRatio(qimage, self._dpi_ratio)
_setDevicePixelRatio(qimage, self.device_pixel_ratio)
# set origin using original QT coordinates
origin = QtCore.QPoint(rect.left(), rect.top())
painter.drawImage(origin, qimage)
Expand Down
7 changes: 3 additions & 4 deletions lib/matplotlib/backends/backend_qt5cairo.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,8 @@ def draw(self):
super().draw()

def paintEvent(self, event):
dpi_ratio = self._dpi_ratio
width = int(dpi_ratio * self.width())
height = int(dpi_ratio * self.height())
width = int(self.device_pixel_ratio * self.width())
height = int(self.device_pixel_ratio * self.height())
if (width, height) != self._renderer.get_canvas_width_height():
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
self._renderer.set_ctx_from_surface(surface)
Expand All @@ -32,7 +31,7 @@ def paintEvent(self, event):
# QImage under PySide on Python 3.
if QT_API == 'PySide':
ctypes.c_long.from_address(id(buf)).value = 1
_setDevicePixelRatio(qimage, dpi_ratio)
_setDevicePixelRatio(qimage, self.device_pixel_ratio)
painter = QtGui.QPainter(self)
painter.eraseRect(event.rect())
painter.drawImage(0, 0, qimage)
Expand Down
26 changes: 12 additions & 14 deletions lib/matplotlib/backends/backend_webagg_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,6 @@ def __init__(self, *args, **kwargs):
# to the connected clients.
self._current_image_mode = 'full'

# Store the DPI ratio of the browser. This is the scaling that
# occurs automatically for all images on a HiDPI display.
self._dpi_ratio = 1

def show(self):
# show the figure window
from matplotlib.pyplot import show
Expand Down Expand Up @@ -311,8 +307,8 @@ def handle_refresh(self, event):
self.draw_idle()

def handle_resize(self, event):
x, y = event.get('width', 800), event.get('height', 800)
x, y = int(x) * self._dpi_ratio, int(y) * self._dpi_ratio
x = int(event.get('width', 800)) * self.device_pixel_ratio
y = int(event.get('height', 800)) * self.device_pixel_ratio
fig = self.figure
# An attempt at approximating the figure size in pixels.
fig.set_size_inches(x / fig.dpi, y / fig.dpi, forward=False)
Expand All @@ -327,14 +323,15 @@ def handle_send_image_mode(self, event):
# The client requests notification of what the current image mode is.
self.send_event('image_mode', mode=self._current_image_mode)

def handle_set_device_pixel_ratio(self, event):
Copy link
Contributor

@anntzer anntzer Feb 10, 2021

Choose a reason for hiding this comment

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

I guess these handlers are effectively private and we reserve the right to change them as we see fit with no deprecation?

Copy link
Member Author

Choose a reason for hiding this comment

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

That's a good point, but I would say so. They're tied directly with the JavaScript side of the implementation, and I don't think we've ever guaranteed anything there.

In this case, it's only a rename for consistency's sake, so I could revert it.

Copy link
Member

Choose a reason for hiding this comment

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

This will likely require a change in ipympl, but I think we should make the changes to keep the function names consistent (in this case).

Copy link
Member Author

Choose a reason for hiding this comment

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

I restored the handler for set_dpi_ratio, as then one can continue using an older version of ipympl with the new Matplotlib that comes out with this change.

self._handle_set_device_pixel_ratio(event.get('device_pixel_ratio', 1))

def handle_set_dpi_ratio(self, event):
dpi_ratio = event.get('dpi_ratio', 1)
if dpi_ratio != self._dpi_ratio:
# We don't want to scale up the figure dpi more than once.
if not hasattr(self.figure, '_original_dpi'):
self.figure._original_dpi = self.figure.dpi
self.figure.dpi = dpi_ratio * self.figure._original_dpi
self._dpi_ratio = dpi_ratio
# This handler is for backwards-compatibility with older ipympl.
self._handle_set_device_pixel_ratio(event.get('dpi_ratio', 1))

def _handle_set_device_pixel_ratio(self, device_pixel_ratio):
if self._set_device_pixel_ratio(device_pixel_ratio):
self._force_full = True
self.draw_idle()

Expand Down Expand Up @@ -426,7 +423,8 @@ def _get_toolbar(self, canvas):
def resize(self, w, h, forward=True):
self._send_event(
'resize',
size=(w / self.canvas._dpi_ratio, h / self.canvas._dpi_ratio),
size=(w / self.canvas.device_pixel_ratio,
h / self.canvas.device_pixel_ratio),
forward=forward)

def set_window_title(self, title):
Expand Down
4 changes: 3 additions & 1 deletion lib/matplotlib/backends/web_backend/js/mpl.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ mpl.figure = function (figure_id, websocket, ondownload, parent_element) {
fig.send_message('supports_binary', { value: fig.supports_binary });
fig.send_message('send_image_mode', {});
if (fig.ratio !== 1) {
fig.send_message('set_dpi_ratio', { dpi_ratio: fig.ratio });
fig.send_message('set_device_pixel_ratio', {
device_pixel_ratio: fig.ratio,
});
}
fig.send_message('refresh', {});
};
Expand Down
23 changes: 12 additions & 11 deletions lib/matplotlib/tests/test_backend_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,10 @@ def on_key_press(event):


@pytest.mark.backend('Qt5Agg', skip_on_importerror=True)
def test_pixel_ratio_change():
def test_device_pixel_ratio_change():
"""
Make sure that if the pixel ratio changes, the figure dpi changes but the
widget remains the same physical size.
widget remains the same logical size.
"""

prop = 'matplotlib.backends.backend_qt5.FigureCanvasQT.devicePixelRatioF'
Expand All @@ -180,10 +180,8 @@ def test_pixel_ratio_change():
qt_canvas = fig.canvas
qt_canvas.show()

def set_pixel_ratio(ratio):
def set_device_pixel_ratio(ratio):
p.return_value = ratio
# Make sure the mocking worked
assert qt_canvas._dpi_ratio == ratio

# The value here doesn't matter, as we can't mock the C++ QScreen
# object, but can override the functional wrapper around it.
Expand All @@ -194,43 +192,46 @@ def set_pixel_ratio(ratio):
qt_canvas.draw()
qt_canvas.flush_events()

# Make sure the mocking worked
assert qt_canvas.device_pixel_ratio == ratio

qt_canvas.manager.show()
size = qt_canvas.size()
screen = qt_canvas.window().windowHandle().screen()
set_pixel_ratio(3)
set_device_pixel_ratio(3)

# The DPI and the renderer width/height change
assert fig.dpi == 360
assert qt_canvas.renderer.width == 1800
assert qt_canvas.renderer.height == 720

# The actual widget size and figure physical size don't change
# The actual widget size and figure logical size don't change.
assert size.width() == 600
assert size.height() == 240
assert qt_canvas.get_width_height() == (600, 240)
assert (fig.get_size_inches() == (5, 2)).all()

set_pixel_ratio(2)
set_device_pixel_ratio(2)

# The DPI and the renderer width/height change
assert fig.dpi == 240
assert qt_canvas.renderer.width == 1200
assert qt_canvas.renderer.height == 480

# The actual widget size and figure physical size don't change
# The actual widget size and figure logical size don't change.
assert size.width() == 600
assert size.height() == 240
assert qt_canvas.get_width_height() == (600, 240)
assert (fig.get_size_inches() == (5, 2)).all()

set_pixel_ratio(1.5)
set_device_pixel_ratio(1.5)

# The DPI and the renderer width/height change
assert fig.dpi == 180
assert qt_canvas.renderer.width == 900
assert qt_canvas.renderer.height == 360

# The actual widget size and figure physical size don't change
# The actual widget size and figure logical size don't change.
assert size.width() == 600
assert size.height() == 240
assert qt_canvas.get_width_height() == (600, 240)
Expand Down