matplotlib
matplotlib copied to clipboard
Add support for High DPI displays to wxAgg backend
Summary
This PR adds support to the wxAgg
backend to corrertly render plots in high DPI displays in all three platforms that the wx toolkit supports: Linux, MACOS and Windows.
This PR closes issue #7720
Background
This PR adds support for high DPI displays using the underlying toolkit support for high DPI displays: For information of wxPython support for High DPI displays, see https://docs.wxpython.org/high_dpi_overview.html
The wxPython toolkit is built on top of of the C++ wxWidgets toolkit. For a more detailed explanation of support for high DPI displays in the wx ecosystem see the WxWidgets explanation: https://docs.wxwidgets.org/3.2/overview_high_dpi.html
Additional details
This PR correctly scales plots in high DPI displays, including figure size, font size and marker and line width. This PR also includes reading toolbar icons from svg instead of png to make them sharp at all dpi's. ~I remark this last feature comes with the caveat that automatically changing icon colors for dark themes is not done.~ [The limitation mentioned in the previous sentence has been removed in the current version of this PR. See discussion below for details. I am crossing out the sentence and adding this note, instead of removing the sentece so the discussion still makes sense]
Additional testing
The variety of use cases and features of matplotlib is very large. I welcome additional testing in all platforms as it is conceivable that there may be features that I have not tested and that I have not added support for. Thank you.
I squashed the commits to not pollute history
Is there anything I can do to help merge this?
I gave this a try on Windows, but it didn't seem to work. According to your links, the application needs to set HiDPI awareness in its manifest. We can't really do that however given that we aren't an application, so I think we might need to abstract out the runtime-setting of the awareness from _tkagg.enable_dpi_awareness
(and not the window procedure replacement as wxAgg should handle that bit for us). I can perhaps look into this some next week if you are not able to.
I unfortunately do not have a Linux HiDPI setup at the moment to test.
Hello @QuLogic Thank you for spending time on this:
the application needs to set HiDPI
From the MSW documentation a Windows script/application that wants to handle its own scaling --instead of letting the OS do the scaling in a generic way with blurry results-- must let the OS know. This is true regardless of the language the application is written in or any frameworks used. In python this is achieved with the following code:
import ctypes
ctypes.windll.shcore.SetProcessDpiAwareness(1)
My understanding is that this code should be part of the end user's application or script, not set by libraries (e.g. matplotlib) or frameworks (e.g. wx) because it is the user who knows if all the libraries and components they use are capable of handling their own scaling. If the end user set's it, wx, matplotlib and other graphical components should read the actual DPI value and handle scaling in a library specific manner. wx already behaves this way. I believe this PR makes matplotlib behave this way when used in conjunction with wx.
If you have a chance, please try adding these lines to your test script and let me know the results. If you encounter any issues I can work with you on them. Thank you.
Additional notes for the record
- A wx multiplatform application would include the above code inside:
if wx.Platform == '__WXMSW__':
- Using a value of one in the code above sets per-process dpi awareness, which is sufficient if one has a single monitor or all monitors have the same dpi. If the application wants to handle multiple monitors with different dpi's then one can set per monitor dpi awareness instead: One would call
SetProcessDpiAwareness(3)
per the reference above, and optionally implement a wx callback in each top level window to react the event of the window being dragged to a monitor with a different DPI in a way suitable to the application. This may involve changing the layout of widgets, etc.
When Matplotlib is the one managing windows (i.e., through pyplot
), then it is the application that should enable HiDPI mode. See the triggers in the Tk backend on the manager for example.
When Matplotlib is the one managing windows (i.e., through pyplot), then it is the application that should enable HiDPI mode.
This makes sense to me
See the triggers in the Tk backend on the manager for example.
In _backend_tk.py
I find the block of code
self._window_dpi_cbname = ''
if _tkagg.enable_dpi_awareness(window_frame, window.tk.interpaddr()):
self._window_dpi_cbname = self._window_dpi.trace_add(
'write', self._update_window_dpi)
This code calls the function defined at _tkagg.cpp
which itself does call the MSW library to set awareness to "per-monitor" awareness unconditionally.
To achieve the same functionality in the wx backend It would be trivial to add the following lines to the wx backend:
if wx.Platform == '__WXMSW__':
import ctypes
ctypes.windll.shcore.SetProcessDpiAwareness(3)
But I do not think in the wx backend case it makes sense to call these lines unconditionally because the typical use of matplotlib with the wx backend is to have figures embedded in larger wx applications rather than in stand alone windows. So I think we need to let the end user retain control of whether dpi awareness should be handled by their application or the OS. What do you think?
If you agree we could add these lines as a function to the wx backend that the user can invoke if they so choose. Or perhaps better, to avoid redundancy, we could document the functionality and have the user call the original MSW SetProcessDPIAwareness
with a value of their choosing.
Is there anything I can do to help merge this?
Thanks for the PR. The text/plot looks great on macOS. However, for the following code, the initial plot screen doesn't looks correct, and the toolbar also looks a little weird.
>>> import matplotlib
>>> matplotlib.use('wxAgg')
>>> import matplotlib.pyplot as plt
>>> plt.plot([1,2,3,4,5])
>>> plt.show()
Initial plot screen from above code
After resize the plot window (drag the edge), the axes area looks great (the toolbar area looks same as above)
Thank you @tianzhuqiao for testing this, and for your feedback. I don't have access to a Mac, but I am going to look into it and try to fix it and, if you don't mind, I will ask you to please test on a Mac. Thank you in advance. I am currently traveling, so it may take me a few weeks to get back to you.
@jmoraleda, sure, no problem. And draw_rubberband
may also need to be updated, otherwise, it shows in a wrong location.
https://github.com/matplotlib/matplotlib/blob/e470c70e99995b47555402e53a98fb6c815d4dce/lib/matplotlib/backends/backend_wx.py#L1124
Thanks. The initial plot and icon size look great on Mac now.
@tianzhuqiao I believe my last commit fixes the draw_rubberband
in all platforms. Thank you for pointing this out!
The tests failed because of a problem with a new version of pytest which has now been yanked. I’m going to close and re-open to restart the CI.
Thanks @jmoraleda , draw_rubberband
works on macOS now.
But I do not think in the wx backend case it makes sense to call these lines unconditionally because the typical use of matplotlib with the wx backend is to have figures embedded in larger wx applications rather than in stand alone windows.
I am a bit curious what this assertion is based on. My expectation is the opposite in that independent of backend we are going to have many more users of pyplot
than who are doing embedding in a large GUI (if for no other reason than the barrier for entry is much lower with pyplot
). On the other hand, wx is after tk in the fallback list so it is possible it only very rarely gets auto-selected. However, leaving that aside we do have users of both pyplot
+ wx and embedding + wx so we need to make this work for both.
So I think we need to let the end user retain control of whether dpi awareness should be handled by their application or the OS. What do you think?
If we go through https://github.com/matplotlib/matplotlib/blob/9618fc6322745834dd098cadecf8e05a0917d498/lib/matplotlib/backends/backend_wx.py#L43-L48 then we are definitely creating the App not the user. In that case we should do the helpful thing and enable hi-dpi like the other platforms and backends rather than making users on wx on windows jump through extra hoops.
A similar bit of logic should probably be pushed to IPython in the case that they make the App.
[edited to fix markup]
Brief answer
I think my last commit does the right thing in all cases that we have discussed :-). Please test if you have a chance!
Longer answer
@tacaswell Thank you for reviewing this and especially for bringing _create_wxapp
to my attention.
I had not paid attention to the fact that this function is only invoked if a wx.App
has not previously been created. This opens a beautiful path to address simultaneously the requirements of both constituencies:
- dpi awareness should be set automatically when matplotlib is creating the application. In paticular, matplotlib code should just work out of the box as expected taking full advantage of monitor resolutions, as @QuLogic pointed out above.
- dpi awareness should be set by the end-user application when embedding matplotlib figures in a pre-existing wx application so that the user can decide what best suites them.
I just pushed a commit that sets per-monitor dpi awareness inside _create_wxapp
thus addressing both requirements.
Now typical matplotlib code such as:
import matplotlib
matplotlib.use('wxAgg')
import matplotlib.pyplot as plt
plt.plot([1,2,3,4,5])
plt.show()
just works out of the box taking advantage of the full monitor resolution as expected. While wx users that embed matplotlib figures in their applications can still set dpi as they wish.
Remark
Per MSW documentation, once a process makes an API call to choose how to handle dpi awareness, future calls will be ignored and the initial setting will not be changed:
Once you set the DPI awareness for a process, it cannot be changed.
(https://learn.microsoft.com/en-us/windows/win32/api/shellscalingapi/ne-shellscalingapi-process_dpi_awareness)
This is why we shouldn't always set per-process dpi awareness when using the wx backend, since there are applications that do not implement support to handle this in their non-matplotlib windows and would thus break if the O/S expected them to.
The remaining linter warning is about a line that is too long. That line is a comment containing a url to MS documentation on the topic of setting dpi awareness. I think it is useful to retain it for future reference, but let me know what you think. Thank you.
Are there any outstanding issues or questions? Is there anything I can do to help merge this?
It's a bit unfortunate that AppVeyor was already in a broken state, because that hid that this was failing:
___ test_interactive_backend[toolmanager-MPLBACKEND=wxagg-BACKEND_DEPS=wx] ____
[gw0] win32 -- Python 3.9.19 C:\Miniconda3-x64\envs\mpl-dev\python.exe
env = {'BACKEND_DEPS': 'wx', 'MPLBACKEND': 'wxagg'}, toolbar = 'toolmanager'
@pytest.mark.parametrize("env", _get_testable_interactive_backends())
@pytest.mark.parametrize("toolbar", ["toolbar2", "toolmanager"])
@pytest.mark.flaky(reruns=3)
def test_interactive_backend(env, toolbar):
if env["MPLBACKEND"] == "macosx":
if toolbar == "toolmanager":
pytest.skip("toolmanager is not implemented for macosx.")
if env["MPLBACKEND"] == "wx":
pytest.skip("wx backend is deprecated; tests failed on appveyor")
try:
> proc = _run_helper(
_test_interactive_impl,
json.dumps({"toolbar": toolbar}),
timeout=_test_timeout,
extra_env=env,
)
C:\projects\matplotlib\lib\matplotlib\tests\test_backends_interactive.py:256:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
C:\projects\matplotlib\lib\matplotlib\testing\__init__.py:126: in subprocess_run_helper
proc = subprocess_run_for_testing(
C:\projects\matplotlib\lib\matplotlib\testing\__init__.py:94: in subprocess_run_for_testing
proc = subprocess.run(
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
input = None, capture_output = False, timeout = 120, check = True
popenargs = (['C:\\Miniconda3-x64\\envs\\mpl-dev\\python.exe', '-c', "import importlib.util;_spec = importlib.util.spec_from_file_...e_from_spec(_spec);_spec.loader.exec_module(_module);_module._test_interactive_impl()", '{"toolbar": "toolmanager"}'],)
kwargs = {'env': {'7ZIP': '"C:\\Program Files\\7-Zip\\7z.exe"', 'ALLUSERSPROFILE': 'C:\\ProgramData', 'APPDATA': 'C:\\Users\\appveyor\\AppData\\Roaming', 'APPVEYOR': 'True', ...}, 'stderr': -1, 'stdout': -1, 'text': True}
process = <Popen: returncode: 1 args: ['C:\\Miniconda3-x64\\envs\\mpl-dev\\python.exe'...>
stdout = ''
stderr = 'C:\\Miniconda3-x64\\envs\\mpl-dev\\lib\\_collections_abc.py:941: UserWarning: Treat the new Tool classes introduced i... ""Assert failure"" failed at ..\\..\\src\\msw\\toolbar.cpp(963) in wxToolBar::Realize(): invalid tool button bitmap\n'
retcode = 1
def run(*popenargs,
input=None, capture_output=False, timeout=None, check=False, **kwargs):
"""Run command with arguments and return a CompletedProcess instance.
The returned instance will have attributes args, returncode, stdout and
stderr. By default, stdout and stderr are not captured, and those attributes
will be None. Pass stdout=PIPE and/or stderr=PIPE in order to capture them.
If check is True and the exit code was non-zero, it raises a
CalledProcessError. The CalledProcessError object will have the return code
in the returncode attribute, and output & stderr attributes if those streams
were captured.
If timeout is given, and the process takes too long, a TimeoutExpired
exception will be raised.
There is an optional argument "input", allowing you to
pass bytes or a string to the subprocess's stdin. If you use this argument
you may not also use the Popen constructor's "stdin" argument, as
it will be used internally.
By default, all communication is in bytes, and therefore any "input" should
be bytes, and the stdout and stderr will be bytes. If in text mode, any
"input" should be a string, and stdout and stderr will be strings decoded
according to locale encoding, or by "encoding" if set. Text mode is
triggered by setting any of text, encoding, errors or universal_newlines.
The other arguments are the same as for the Popen constructor.
"""
if input is not None:
if kwargs.get('stdin') is not None:
raise ValueError('stdin and input arguments may not both be used.')
kwargs['stdin'] = PIPE
if capture_output:
if kwargs.get('stdout') is not None or kwargs.get('stderr') is not None:
raise ValueError('stdout and stderr arguments may not be used '
'with capture_output.')
kwargs['stdout'] = PIPE
kwargs['stderr'] = PIPE
with Popen(*popenargs, **kwargs) as process:
try:
stdout, stderr = process.communicate(input, timeout=timeout)
except TimeoutExpired as exc:
process.kill()
if _mswindows:
# Windows accumulates the output in a single blocking
# read() call run on child threads, with the timeout
# being done in a join() on those threads. communicate()
# _after_ kill() is required to collect that and add it
# to the exception.
exc.stdout, exc.stderr = process.communicate()
else:
# POSIX _communicate already populated the output so
# far into the TimeoutExpired exception.
process.wait()
raise
except: # Including KeyboardInterrupt, communicate handled that.
process.kill()
# We don't call process.wait() as .__exit__ does that for us.
raise
retcode = process.poll()
if check and retcode:
> raise CalledProcessError(retcode, process.args,
output=stdout, stderr=stderr)
E subprocess.CalledProcessError: Command '['C:\\Miniconda3-x64\\envs\\mpl-dev\\python.exe', '-c', "import importlib.util;_spec = importlib.util.spec_from_file_location('matplotlib.tests.test_backends_interactive', 'C:\\\\projects\\\\matplotlib\\\\lib\\\\matplotlib\\\\tests\\\\test_backends_interactive.py');_module = importlib.util.module_from_spec(_spec);_spec.loader.exec_module(_module);_module._test_interactive_impl()", '{"toolbar": "toolmanager"}']' returned non-zero exit status 1.
C:\Miniconda3-x64\envs\mpl-dev\lib\subprocess.py:528: CalledProcessError
During handling of the above exception, another exception occurred:
env = {'BACKEND_DEPS': 'wx', 'MPLBACKEND': 'wxagg'}, toolbar = 'toolmanager'
@pytest.mark.parametrize("env", _get_testable_interactive_backends())
@pytest.mark.parametrize("toolbar", ["toolbar2", "toolmanager"])
@pytest.mark.flaky(reruns=3)
def test_interactive_backend(env, toolbar):
if env["MPLBACKEND"] == "macosx":
if toolbar == "toolmanager":
pytest.skip("toolmanager is not implemented for macosx.")
if env["MPLBACKEND"] == "wx":
pytest.skip("wx backend is deprecated; tests failed on appveyor")
try:
proc = _run_helper(
_test_interactive_impl,
json.dumps({"toolbar": toolbar}),
timeout=_test_timeout,
extra_env=env,
)
except subprocess.CalledProcessError as err:
> pytest.fail(
"Subprocess failed to test intended behavior\n"
+ str(err.stderr))
E Failed: Subprocess failed to test intended behavior
E C:\Miniconda3-x64\envs\mpl-dev\lib\_collections_abc.py:941: UserWarning: Treat the new Tool classes introduced in v1.5 as experimental for now; the API and rcParam may change in future versions.
E self[key] = other[key]
E Traceback (most recent call last):
E File "<string>", line 1, in <module>
E File "C:\projects\matplotlib\lib\matplotlib\tests\test_backends_interactive.py", line 182, in _test_interactive_impl
E plt.figure()
E File "C:\projects\matplotlib\lib\matplotlib\pyplot.py", line 1005, in figure
E manager = new_figure_manager(
E File "C:\projects\matplotlib\lib\matplotlib\pyplot.py", line 528, in new_figure_manager
E return _get_backend_mod().new_figure_manager(*args, **kwargs)
E File "C:\projects\matplotlib\lib\matplotlib\backend_bases.py", line 3490, in new_figure_manager
E return cls.new_figure_manager_given_figure(num, fig)
E File "C:\projects\matplotlib\lib\matplotlib\backend_bases.py", line 3495, in new_figure_manager_given_figure
E return cls.FigureCanvas.new_manager(figure, num)
E File "C:\projects\matplotlib\lib\matplotlib\backend_bases.py", line 1803, in new_manager
E return cls.manager_class.create_with_canvas(cls, figure, num)
E File "C:\projects\matplotlib\lib\matplotlib\backends\backend_wx.py", line 967, in create_with_canvas
E frame = FigureFrameWx(num, figure, canvas_class=canvas_class)
E File "C:\projects\matplotlib\lib\matplotlib\backends\backend_wx.py", line 909, in __init__
E manager = FigureManagerWx(self.canvas, num, self)
E File "C:\projects\matplotlib\lib\matplotlib\backends\backend_wx.py", line 961, in __init__
E super().__init__(canvas, num)
E File "C:\projects\matplotlib\lib\matplotlib\backend_bases.py", line 2672, in __init__
E tools.add_tools_to_container(self.toolbar)
E File "C:\projects\matplotlib\lib\matplotlib\backend_tools.py", line 1007, in add_tools_to_container
E container.add_tool(tool, group, position)
E File "C:\projects\matplotlib\lib\matplotlib\backend_bases.py", line 3330, in add_tool
E self.add_toolitem(tool.name, group, position,
E File "C:\projects\matplotlib\lib\matplotlib\backends\backend_wx.py", line 1218, in add_toolitem
E self.Realize()
E wx._core.wxAssertionError: C++ assertion ""Assert failure"" failed at ..\..\src\msw\toolbar.cpp(963) in wxToolBar::Realize(): invalid tool button bitmap
C:\projects\matplotlib\lib\matplotlib\tests\test_backends_interactive.py:263: Failed
@QuLogic What is the best way of running locally the exact appveyor command that fails?
When I run pytest --pyargs matplotlib.tests
I get 4 errors but none is related to the wx backend:
================================= test session starts ==================================
platform linux -- Python 3.11.2, pytest-7.2.1, pluggy-1.0.0+repack
rootdir: /home/jorge/vc/matplotlib, configfile: pyproject.toml
plugins: anyio-3.6.2
collected 9365 items / 4 errors / 1 skipped
======================================== ERRORS ========================================
_________________________ ERROR collecting test_backend_pgf.py _________________________
/usr/lib/python3/dist-packages/matplotlib/tests/test_backend_pgf.py:98: in <module>
@pytest.mark.skipif(not _has_tex_package('type1ec'), reason='needs type1ec.sty')
/usr/lib/python3/dist-packages/matplotlib/testing/__init__.py:112: in _has_tex_package
mpl.dviread._find_tex_file(f"{package}.sty")
E AttributeError: module 'matplotlib.dviread' has no attribute '_find_tex_file'
_______________________ ERROR collecting test_preprocess_data.py _______________________
ImportError while importing test module '/usr/lib/python3/dist-packages/matplotlib/tests/test_preprocess_data.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3/dist-packages/matplotlib/tests/test_preprocess_data.py:9: in <module>
from matplotlib.testing import subprocess_run_for_testing
E ImportError: cannot import name 'subprocess_run_for_testing' from 'matplotlib.testing' (/usr/lib/python3/dist-packages/matplotlib/testing/__init__.py)
___________________________ ERROR collecting test_pyplot.py ____________________________
ImportError while importing test module '/usr/lib/python3/dist-packages/matplotlib/tests/test_pyplot.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3/dist-packages/matplotlib/tests/test_pyplot.py:10: in <module>
from matplotlib.testing import subprocess_run_for_testing
E ImportError: cannot import name 'subprocess_run_for_testing' from 'matplotlib.testing' (/usr/lib/python3/dist-packages/matplotlib/testing/__init__.py)
__________________________ ERROR collecting test_sphinxext.py __________________________
ImportError while importing test module '/usr/lib/python3/dist-packages/matplotlib/tests/test_sphinxext.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3/dist-packages/matplotlib/tests/test_sphinxext.py:9: in <module>
from matplotlib.testing import subprocess_run_for_testing
E ImportError: cannot import name 'subprocess_run_for_testing' from 'matplotlib.testing' (/usr/lib/python3/dist-packages/matplotlib/testing/__init__.py)
=================================== warnings summary ===================================
../../../../usr/lib/python3/dist-packages/mpl_toolkits/mplot3d/axes3d.py:35
/usr/lib/python3/dist-packages/mpl_toolkits/mplot3d/axes3d.py:35: MatplotlibDeprecationWarning: Importing matplotlib.tri.triangulation was deprecated in Matplotlib 3.7 and will be removed two minor releases later. All functionality is available via the top-level module matplotlib.tri
from matplotlib.tri.triangulation import Triangulation
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=============================== short test summary info ================================
ERROR test_backend_pgf.py - AttributeError: module 'matplotlib.dviread' has no attribute '_find_tex_file'
ERROR test_preprocess_data.py
ERROR test_pyplot.py
ERROR test_sphinxext.py
!!!!!!!!!!!!!!!!!!!!!!! Interrupted: 4 errors during collection !!!!!!!!!!!!!!!!!!!!!!!!
======================= 1 skipped, 1 warning, 4 errors in 10.79s =======================
You should be able to run any script and set plt.rcParams['toolbar'] = 'toolmanager'
to reproduce; at least the missing icons were reproducible on Linux that way.
Oh also, if you're getting those errors when running pytest, it appears you've not installed Matplotlib correctly, and probably have some old copy in your environment.
You should be able to run any script and set plt.rcParams['toolbar'] = 'toolmanager' to reproduce; at least the missing icons were reproducible on Linux that way.
I was able to reproduce the problem of the missing toolmanager icons running examples/user_interfaces/toolmanager_sgskip.py
after adding at the top
import matplotlib
matplotlib.use('wxAgg')
but that is fixed now with #28007, which I think we should merge.
But #28007 does not fix the appveyor problem and I don't know how to run tests\test_backends_interactive.py
which seems to be the script failing in appveyor.
Oh also, if you're getting those errors when running pytest, it appears you've not installed Matplotlib correctly, and probably have some old copy in your environment.
Very possible. My default installation of matplotlib in debian is via apt and the package manager, and my earlier run of the tests was on a patched version of that that includes the latest wx backend code, instead of in a clean installation of all the latest code in a venv
. But my point was that no test failed related to wx
.
But my point was that no test failed related to
wx
.
No tests ran at all, as it failed to import.