diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fc9f855 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" # Location of your workflow files + schedule: + interval: "weekly" # Options: daily, weekly, monthly diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..031ede2 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,43 @@ +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: "15 9 * * 3" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ python ] + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 0000000..f8c31fa --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,104 @@ + +name: Validate Python Code +permissions: + contents: read + +on: + push: + branches: + - main + pull_request: + branches: + - develop + - main + +jobs: + test-mac-linux: + + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ["3.11", "3.12", "3.13", "3.13t"] + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install OS dependencies + run: | + case "${{ runner.os }}" in + Linux) + sudo apt-get update -yy + sudo apt-get install -yy \ + ccache \ + inkscape \ + ghostscript + if [[ "${{ matrix.os }}" = ubuntu-20.04 ]]; then + sudo apt install -yy libopengl0 + fi + ;; + macOS) + ;; + esac + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + flake8 matplotview --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 matplotview --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + id: pytest + run: | + pytest + + - name: Upload images on failure + uses: actions/upload-artifact@v4 + if: ${{ failure() && steps.pytest.conclusion == 'failure' }} + with: + name: test-result-images + retention-days: 1 + path: result_images/ + + test-windows: + + runs-on: windows-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13", "3.13t"] + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest + pip install -r requirements.txt + - name: Test with pytest + id: pytest + run: | + pytest + + - name: Upload images on failure + uses: actions/upload-artifact@v4 + if: ${{ failure() && steps.pytest.conclusion == 'failure' }} + with: + name: test-result-images + retention-days: 1 + path: result_images/ diff --git a/.gitignore b/.gitignore index c92265d..05a3908 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ result_images .pytest_cache dist *.egg-info +docs/_build/ +docs/api/generated +docs/examples/ \ No newline at end of file diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..e88e6c7 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,13 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +sphinx: + configuration: docs/conf.py + +python: + install: + - requirements: docs/requirements.txt diff --git a/README.md b/README.md index ceab1e2..788bca6 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,27 @@ # matplotview -#### A library for creating lightweight views of matplotlib axes. +#### A small library for creating lightweight views of matplotlib axes. matplotview provides a simple interface for creating "views" of matplotlib axes, providing a simple way of displaying overviews and zoomed views of data without plotting data twice. -## Usage +## Installation -matplotview provides two methods, `view`, and `inset_zoom_axes`. The `view` -method accepts two `Axes`, and makes the first axes a view of the second. The -`inset_zoom_axes` method provides the same functionality as `Axes.inset_axes`, -but the returned inset axes is configured to be a view of the parent axes. - -## Examples - -An example of two axes showing the same plot. -```python -from matplotview import view -import matplotlib.pyplot as plt -import numpy as np - -fig, (ax1, ax2) = plt.subplots(1, 2) - -# Plot a line, circle patch, some text, and an image... -ax1.plot([i for i in range(10)], "r") -ax1.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) -ax1.text(10, 10, "Hello World!", size=20) -ax1.imshow(np.random.rand(30, 30), origin="lower", cmap="Blues", alpha=0.5, - interpolation="nearest") - -# Turn axes 2 into a view of axes 1. -view(ax2, ax1) -# Modify the second axes data limits to match the first axes... -ax2.set_aspect(ax1.get_aspect()) -ax2.set_xlim(ax1.get_xlim()) -ax2.set_ylim(ax1.get_ylim()) - -fig.tight_layout() -fig.show() +You can install matplotview using pip: +```bash +pip install matplotview ``` -![First example plot results, two views of the same plot.](https://user-images.githubusercontent.com/47544550/149814592-dd815f95-c3ef-406d-bd7e-504859c836bf.png) -An inset axes example . -```python -from matplotlib import cbook -import matplotlib.pyplot as plt -import numpy as np -from matplotview import inset_zoom_axes +## Examples -def get_demo_image(): - z = cbook.get_sample_data("axes_grid/bivariate_normal.npy", np_load=True) - # z is a numpy array of 15x15 - return z, (-3, 4, -4, 3) +Examples can be found in the example gallery: -fig, ax = plt.subplots(figsize=[5, 4]) +[https://matplotview.readthedocs.io/en/latest/examples/index.html](https://matplotview.readthedocs.io/en/latest/examples/index.html) -# Make the data... -Z, extent = get_demo_image() -Z2 = np.zeros((150, 150)) -ny, nx = Z.shape -Z2[30:30+ny, 30:30+nx] = Z +## Documentation -ax.imshow(Z2, extent=extent, interpolation='nearest', origin="lower") +Additional documentation can be found at the link below: -# Creates an inset axes with automatic view of the parent axes... -axins = inset_zoom_axes(ax, [0.5, 0.5, 0.47, 0.47]) -# Set limits to sub region of the original image -x1, x2, y1, y2 = -1.5, -0.9, -2.5, -1.9 -axins.set_xlim(x1, x2) -axins.set_ylim(y1, y2) -axins.set_xticklabels([]) -axins.set_yticklabels([]) +[https://matplotview.readthedocs.io/en/latest/](https://matplotview.readthedocs.io/en/latest/) -ax.indicate_inset_zoom(axins, edgecolor="black") -fig.show() -``` -![Second example plot results, an inset axes showing a zoom view of an image.](https://user-images.githubusercontent.com/47544550/149814558-c2b1228d-2e5d-41be-86c0-f5dd01d42884.png) \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/gallery_mods.css b/docs/_static/gallery_mods.css new file mode 100644 index 0000000..7da4417 --- /dev/null +++ b/docs/_static/gallery_mods.css @@ -0,0 +1,20 @@ + +.sphx-glr-thumbcontainer[tooltip]:hover:after { + background: var(--sg-tooltip-background); + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + color: var(--sg-tooltip-foreground); + content: ""; + opacity: 0.35; + padding: 10px; + z-index: 98; + width: 100%; + height: 100%; + position: absolute; + pointer-events: none; + top: 0; + box-sizing: border-box; + overflow: hidden; + backdrop-filter: blur(3px); +} \ No newline at end of file diff --git a/docs/api/index.rst b/docs/api/index.rst new file mode 100644 index 0000000..f66b580 --- /dev/null +++ b/docs/api/index.rst @@ -0,0 +1,13 @@ +API +=== + +The public facing functions of matplotview. + +.. autosummary:: + :toctree: generated + + matplotview.view + matplotview.stop_viewing + matplotview.inset_zoom_axes + + diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..462b3ea --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,47 @@ +from pathlib import Path +import sys + +# Add project root directory to python path... +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + + +project = 'matplotview' +copyright = '2022, Isaac Robinson' +author = 'Isaac Robinson' +release = '1.0.0' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.autosummary', + 'sphinx.ext.doctest', + 'sphinx.ext.intersphinx', + 'sphinx.ext.viewcode', + 'numpydoc', + 'matplotlib.sphinxext.mathmpl', + 'matplotlib.sphinxext.plot_directive', + 'sphinx_gallery.gen_gallery' +] + +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +from sphinx_gallery.sorting import FileNameSortKey + +sphinx_gallery_conf = { + "examples_dirs": "../examples", + "gallery_dirs": "examples", + "line_numbers": True, + "within_subsection_order": FileNameSortKey +} + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'alabaster' +html_static_path = ['_static'] +html_css_files = ['gallery_mods.css'] + +plot_include_source = True diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..0d6a0e0 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,22 @@ +.. matplotview documentation master file, created by + sphinx-quickstart on Sat Aug 13 19:55:28 2022. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Matplotview |release| Documentation +=================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + installation + examples/index + api/index + + +Additional Links +================ + +* :ref:`genindex` +* :ref:`search` diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..4051030 --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,9 @@ +Installation +============ + +Matplotview can be installed using `pip `__: + +.. code-block:: bash + + pip install matplotview + diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..32bb245 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..9fb3f41 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,13 @@ +matplotlib +numpy +sphinx +sphinx-gallery +sphinxcontrib-applehelp +sphinxcontrib-devhelp +sphinxcontrib-htmlhelp +sphinxcontrib-jsmath +sphinxcontrib-qthelp +sphinxcontrib-serializinghtml +numpy +numpydoc +alabaster \ No newline at end of file diff --git a/examples/README.rst b/examples/README.rst new file mode 100644 index 0000000..43dcd19 --- /dev/null +++ b/examples/README.rst @@ -0,0 +1,7 @@ +Examples +======== + +Because of the way matplotview is designed, it can work with any Axes and projection +types, and works with all the default projection modes included in matplotlib. +The following examples showcase using matplotview in several different scenarios and +with different projections. \ No newline at end of file diff --git a/examples/plot_00_simplest_example.py b/examples/plot_00_simplest_example.py new file mode 100644 index 0000000..8075d2f --- /dev/null +++ b/examples/plot_00_simplest_example.py @@ -0,0 +1,23 @@ +""" +The Simplest View +================= + +The simplest example: We make a view of a line! Views can be created quickly +using :meth:`matplotview.view` . +""" + +from matplotview import view +import matplotlib.pyplot as plt + +fig, (ax1, ax2) = plt.subplots(1, 2) + +# Plot a line in the first axes. +ax1.plot([i for i in range(10)], "-o") + +# Create a view! Turn axes 2 into a view of axes 1. +view(ax2, ax1) +# Modify the second axes data limits so we get a slightly zoomed out view +ax2.set_xlim(-5, 15) +ax2.set_ylim(-5, 15) + +fig.show() \ No newline at end of file diff --git a/examples/plot_01_multiple_artist_view.py b/examples/plot_01_multiple_artist_view.py new file mode 100644 index 0000000..0f3a649 --- /dev/null +++ b/examples/plot_01_multiple_artist_view.py @@ -0,0 +1,29 @@ +""" +A View With Several Plot Elements +================================= + +A simple example with an assortment of plot elements. +""" + +from matplotview import view +import matplotlib.pyplot as plt +import numpy as np + +fig, (ax1, ax2) = plt.subplots(1, 2) + +# Plot a line, circle patch, some text, and an image... +ax1.plot([i for i in range(10)], "r") +ax1.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) +ax1.text(10, 10, "Hello World!", size=20) +ax1.imshow(np.random.rand(30, 30), origin="lower", cmap="Blues", alpha=0.5, + interpolation="nearest") + +# Turn axes 2 into a view of axes 1. +view(ax2, ax1) +# Modify the second axes data limits to match the first axes... +ax2.set_aspect(ax1.get_aspect()) +ax2.set_xlim(ax1.get_xlim()) +ax2.set_ylim(ax1.get_ylim()) + +fig.tight_layout() +fig.show() \ No newline at end of file diff --git a/examples/plot_02_simple_inset_view.py b/examples/plot_02_simple_inset_view.py new file mode 100644 index 0000000..0995e07 --- /dev/null +++ b/examples/plot_02_simple_inset_view.py @@ -0,0 +1,43 @@ +""" +Create An Inset Axes Without Plotting Twice +=========================================== + +:meth:`matplotview.inset_zoom_axes` can be utilized to create inset axes where we +don't have to plot the parent axes data twice. +""" + +from matplotlib import cbook +import matplotlib.pyplot as plt +import numpy as np +from matplotview import inset_zoom_axes + +def get_demo_image(): + z = cbook.get_sample_data("axes_grid/bivariate_normal.npy", np_load=True) + # z is a numpy array of 15x15 + return z, (-3, 4, -4, 3) + +fig, ax = plt.subplots() + +# Make the data... +Z, extent = get_demo_image() +Z2 = np.zeros((150, 150)) +ny, nx = Z.shape +Z2[30:30+ny, 30:30+nx] = Z + +ax.imshow(Z2, extent=extent, interpolation='nearest', origin="lower") + +# Creates an inset axes with automatic view of the parent axes... +axins = inset_zoom_axes(ax, [0.5, 0.5, 0.47, 0.47]) +# Set limits to sub region of the original image +x1, x2, y1, y2 = -1.5, -0.9, -2.5, -1.9 +axins.set_xlim(x1, x2) +axins.set_ylim(y1, y2) + +# Remove the tick labels from the inset axes +axins.set_xticklabels([]) +axins.set_yticklabels([]) + +# Draw the indicator or zoom lines. +ax.indicate_inset_zoom(axins, edgecolor="black") + +fig.show() \ No newline at end of file diff --git a/examples/plot_03_view_with_annotations.py b/examples/plot_03_view_with_annotations.py new file mode 100644 index 0000000..c89c5c2 --- /dev/null +++ b/examples/plot_03_view_with_annotations.py @@ -0,0 +1,48 @@ +""" +View With Annotations +===================== + +Matplotview's views are also regular matplotlib `Axes `_, +meaning they support regular plotting on top of their viewing capabilities, allowing +for annotations, as shown below. +""" + +# All the same as from the prior inset axes example... +from matplotlib import cbook +import matplotlib.pyplot as plt +import numpy as np +from matplotview import inset_zoom_axes + + +def get_demo_image(): + z = cbook.get_sample_data("axes_grid/bivariate_normal.npy", np_load=True) + return z, (-3, 4, -4, 3) + + +fig, ax = plt.subplots() + +Z, extent = get_demo_image() +Z2 = np.zeros((150, 150)) +ny, nx = Z.shape +Z2[30:30 + ny, 30:30 + nx] = Z + +ax.imshow(Z2, extent=extent, interpolation='nearest', origin="lower") + +axins = inset_zoom_axes(ax, [0.5, 0.5, 0.47, 0.47]) + +x1, x2, y1, y2 = -1.5, -0.9, -2.5, -1.9 +axins.set_xlim(x1, x2) +axins.set_ylim(y1, y2) + +# We'll annotate the 'interesting' spot in the view.... +axins.annotate( + "Interesting Feature", (-1.3, -2.25), (0.1, 0.1), + textcoords="axes fraction", arrowprops=dict(arrowstyle="->") +) + +axins.set_xticklabels([]) +axins.set_yticklabels([]) + +ax.indicate_inset_zoom(axins, edgecolor="black") + +fig.show() \ No newline at end of file diff --git a/examples/plot_04_sierpinski_triangle.py b/examples/plot_04_sierpinski_triangle.py new file mode 100644 index 0000000..18dae70 --- /dev/null +++ b/examples/plot_04_sierpinski_triangle.py @@ -0,0 +1,49 @@ +""" +Sierpiński Triangle With Recursive Views +======================================== + +Matplotview's views support recursive drawing of other views and themselves to a +configurable depth. This feature allows matplotview to be used to generate fractals, +such as a sierpiński triangle as shown in the following example. +""" + +import matplotlib.pyplot as plt +import matplotview as mpv +from matplotlib.patches import PathPatch +from matplotlib.path import Path +from matplotlib.transforms import Affine2D + +# We'll plot a white upside down triangle inside of black one, and then use +# 3 views to draw all the rest of the recursions of the sierpiński triangle. +outside_color = "black" +inner_color = "white" + +t = Affine2D().scale(-0.5) + +outer_triangle = Path.unit_regular_polygon(3) +inner_triangle = t.transform_path(outer_triangle) +b = outer_triangle.get_extents() + +fig, ax = plt.subplots(1) +ax.set_aspect(1) + +ax.add_patch(PathPatch(outer_triangle, fc=outside_color, ec=[0] * 4)) +ax.add_patch(PathPatch(inner_triangle, fc=inner_color, ec=[0] * 4)) +ax.set_xlim(b.x0, b.x1) +ax.set_ylim(b.y0, b.y1) + +ax_locs = [ + [0, 0, 0.5, 0.5], + [0.5, 0, 0.5, 0.5], + [0.25, 0.5, 0.5, 0.5] +] + +for loc in ax_locs: + # Here we limit the render depth to 6 levels in total for each zoom view.... + inax = mpv.inset_zoom_axes(ax, loc, render_depth=6) + inax.set_xlim(b.x0, b.x1) + inax.set_ylim(b.y0, b.y1) + inax.axis("off") + inax.patch.set_visible(False) + +fig.show() \ No newline at end of file diff --git a/examples/plot_05_3d_views.py b/examples/plot_05_3d_views.py new file mode 100644 index 0000000..d083998 --- /dev/null +++ b/examples/plot_05_3d_views.py @@ -0,0 +1,30 @@ +""" +Viewing 3D Axes +=============== + +Matplotview has built-in support for viewing 3D axes and plots. +""" +import matplotlib.pyplot as plt +import numpy as np +from matplotview import view + +X = Y = np.arange(-5, 5, 0.25) +X, Y = np.meshgrid(X, Y) +Z = np.sin(np.sqrt(X ** 2 + Y ** 2)) + +# Make some 3D plots... +fig, (ax1, ax2) = plt.subplots(1, 2, subplot_kw=dict(projection="3d")) + +# Plot our surface +ax1.plot_surface(X, Y, Z, cmap="plasma") + +# Axes 2 is now viewing axes 1. +view(ax2, ax1) + +# Update the limits, and set the elevation higher, so we get a better view of the inside of the surface. +ax2.view_init(elev=80) +ax2.set_xlim(-10, 10) +ax2.set_ylim(-10, 10) +ax2.set_zlim(-2, 2) + +fig.show() \ No newline at end of file diff --git a/examples/plot_06_polar_views.py b/examples/plot_06_polar_views.py new file mode 100644 index 0000000..1fcfe7b --- /dev/null +++ b/examples/plot_06_polar_views.py @@ -0,0 +1,30 @@ +""" +Viewing Polar Axes +================== + +Views also support viewing polar axes. +""" + +import numpy as np +import matplotlib.pyplot as plt +from matplotview import view + +# Create the data... +r = np.arange(0, 2, 0.01) +theta = 2 * np.pi * r + +fig, (ax, ax2) = plt.subplots(1, 2, subplot_kw=dict(projection='polar')) + +ax.plot(theta, r) +ax.set_rmax(2) +ax.set_rticks([0.5, 1, 1.5, 2]) # Less radial ticks +ax.set_rlabel_position(-22.5) # Move radial labels away from plotted line +# Include a grid +ax.grid(True) + +# ax2 is now zoomed in on ax. +view(ax2, ax) + +fig.tight_layout() + +fig.show() \ No newline at end of file diff --git a/examples/plot_07_geographic_viewing.py b/examples/plot_07_geographic_viewing.py new file mode 100644 index 0000000..1dbc310 --- /dev/null +++ b/examples/plot_07_geographic_viewing.py @@ -0,0 +1,30 @@ +""" +Viewing Geographic Projections +============================== + +Matplotview also works with matplotlib's built in geographic projections. +""" +import matplotlib.pyplot as plt +import numpy as np +from matplotview import view + +x = np.linspace(-2.5, 2.5, 20) +y = np.linspace(-1, 1, 20) +circ_gen = lambda: plt.Circle((1.5, 0.25), 0.7, ec="black", fc="blue") + +fig_test = plt.figure() + +# Plot in 2 seperate geographic projections... +ax_t1 = fig_test.add_subplot(1, 2, 1, projection="hammer") +ax_t2 = fig_test.add_subplot(1, 2, 2, projection="lambert") + +ax_t1.grid(True) +ax_t2.grid(True) + +ax_t1.plot(x, y) +ax_t1.add_patch(circ_gen()) + +view(ax_t2, ax_t1) + +fig_test.tight_layout() +fig_test.savefig("test7.png") diff --git a/examples/plot_08_viewing_2_axes.py b/examples/plot_08_viewing_2_axes.py new file mode 100644 index 0000000..16fdea6 --- /dev/null +++ b/examples/plot_08_viewing_2_axes.py @@ -0,0 +1,28 @@ +""" +Viewing Multiple Axes From A Single View +======================================== + +Views can view multiple axes at the same time, by simply calling :meth:`matplotview.view` multiple times. +""" +import matplotlib.pyplot as plt +from matplotview import view + +fig, (ax1, ax2, ax3) = plt.subplots(1, 3) + +# We'll plot 2 circles in axes 1 and 3. +ax1.add_patch(plt.Circle((1, 1), 1.5, ec="black", fc=(0, 0, 1, 0.5))) +ax3.add_patch(plt.Circle((3, 1), 1.5, ec="black", fc=(1, 0, 0, 0.5))) +for ax in (ax1, ax3): + ax.set_aspect(1) + ax.relim() + ax.autoscale_view() + +# Axes 2 is a view of 1 and 3 at the same time (view returns the axes it turns into a view...) +view(view(ax2, ax1), ax3) + +# Change data limits, so we can see the entire 'venn diagram' +ax2.set_aspect(1) +ax2.set_xlim(-0.5, 4.5) +ax2.set_ylim(-0.5, 2.5) + +fig.show() \ No newline at end of file diff --git a/examples/plot_09_artist_filtering.py b/examples/plot_09_artist_filtering.py new file mode 100644 index 0000000..e528fe4 --- /dev/null +++ b/examples/plot_09_artist_filtering.py @@ -0,0 +1,32 @@ +""" +Filtering Artists in a View +=========================== + +:meth:`matplotview.view` supports filtering out artist instances and types using the `filter_set` parameter, +which accepts an iterable of artists types and instances. +""" +import matplotlib.pyplot as plt +from matplotview import view + +fig, (ax1, ax2, ax3) = plt.subplots(3, 1) + +# Plot a line, circle patch, and some text in axes 1 +ax1.set_title("Original Plot") +ax1.plot([(i / 10) for i in range(10)], [(i / 10) for i in range(10)], "r") +ax1.add_patch(plt.Circle((0.5, 0.5), 0.25, ec="black", fc="blue")) +text = ax1.text(0.2, 0.2, "Hello World!", size=12) + +# Axes 2 is viewing axes 1, but filtering circles... +ax2.set_title("View Filtering Out Circles") +view(ax2, ax1, filter_set=[plt.Circle]) # We can pass artist types +ax2.set_xlim(ax1.get_xlim()) +ax2.set_ylim(ax1.get_ylim()) + +# Axes 3 is viewing axes 1, but filtering the text artist +ax3.set_title("View Filtering Out Just the Text Artist.") +view(ax3, ax1, filter_set=[text]) # We can also pass artist instances... +ax3.set_xlim(ax1.get_xlim()) +ax3.set_ylim(ax1.get_ylim()) + +fig.tight_layout() +fig.show() \ No newline at end of file diff --git a/examples/plot_10_line_scaling.py b/examples/plot_10_line_scaling.py new file mode 100644 index 0000000..9fe6179 --- /dev/null +++ b/examples/plot_10_line_scaling.py @@ -0,0 +1,29 @@ +""" +Disabling Line Scaling +====================== + +By default, matplotview scales the line thickness settings for lines and markers to match the zoom level. +This can be disabled via the `scale_lines` parameter of :meth:`matplotview.view`. +""" +import matplotlib.pyplot as plt +from matplotview import view + +fig, (ax1, ax2, ax3) = plt.subplots(3, 1) + +# Plot a line, and circle patch in axes 1 +ax1.set_title("Original Plot") +ax1.plot([(i / 10) for i in range(10)], [(i / 10) for i in range(10)], "r-") +ax1.add_patch(plt.Circle((0.5, 0.5), 0.1, ec="black", fc="blue")) + +ax2.set_title("Zoom View With Line Scaling") +view(ax2, ax1, scale_lines=True) # Default, line scaling is ON +ax2.set_xlim(0.33, 0.66) +ax2.set_ylim(0.33, 0.66) + +ax3.set_title("Zoom View Without Line Scaling") +view(ax3, ax1, scale_lines=False) # Line scaling is OFF +ax3.set_xlim(0.33, 0.66) +ax3.set_ylim(0.33, 0.66) + +fig.tight_layout() +fig.show() \ No newline at end of file diff --git a/examples/plot_11_image_interpolation.py b/examples/plot_11_image_interpolation.py new file mode 100644 index 0000000..06ecde5 --- /dev/null +++ b/examples/plot_11_image_interpolation.py @@ -0,0 +1,52 @@ +""" +Image Interpolation Methods +=========================== + +:meth:`matplotview.view` and :meth:`matplotview.inset_zoom_axes` support specifying an +image interpolation method via the `image_interpolation` parameter. This image interpolation +method is used to resize images when displaying them in the view. +""" +import matplotlib.pyplot as plt +from matplotview import view +import numpy as np + +fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2) + +fig.suptitle("Different interpolations when zoomed in on the bottom left corner.") + +ax1.set_title("Original") +ax1.imshow(np.random.rand(100, 100), cmap="Blues", origin="lower") +ax1.add_patch(plt.Rectangle((0, 0), 10, 10, ec="red", fc=(0, 0, 0, 0))) + +for ax, interpolation, title in zip([ax2, ax3, ax4], ["nearest", "bilinear", "bicubic"], ["Nearest (Default)", "Bilinear", "Cubic"]): + ax.set_title(title) + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.set_aspect("equal") + view(ax, ax1, image_interpolation=interpolation) + +fig.tight_layout() +fig.show() + +#%% +# If you want to avoid interpolation artifacts, you can use `pcolormesh` instead of `imshow`. + +import matplotlib.pyplot as plt +from matplotview import view +import numpy as np + +fig, (ax1, ax2) = plt.subplots(1, 2) + +ax1.set_title("Original") +ax1.pcolormesh(np.random.rand(100, 100), cmap="Blues") +ax1.add_patch(plt.Rectangle((0, 0), 10, 10, ec="red", fc=(0, 0, 0, 0))) +ax1.set_aspect("equal") + +ax2.set_title("Zoomed in View") +ax2.set_xlim(0, 10) +ax2.set_ylim(0, 10) +ax2.set_aspect("equal") +view(ax2, ax1) + +fig.tight_layout() +fig.show() \ No newline at end of file diff --git a/examples/plot_12_editing_view_properties.py b/examples/plot_12_editing_view_properties.py new file mode 100644 index 0000000..b5cc3cb --- /dev/null +++ b/examples/plot_12_editing_view_properties.py @@ -0,0 +1,37 @@ +""" +Editing View Properties +======================= + +A view's properties can be edited by simply calling :meth:`matplotview.view` with the same axes arguments. +To stop a viewing, :meth:`matplotview.stop_viewing` can be used. +""" +import matplotlib.pyplot as plt +from matplotview import view, stop_viewing + +fig, (ax1, ax2, ax3) = plt.subplots(3, 1) + +# Plot a line, and circle patch in axes 1 +ax1.set_title("Original Plot") +ax1.plot([(i / 10) for i in range(10)], [(i / 10) for i in range(10)], "r-") +ax1.add_patch(plt.Circle((0.5, 0.5), 0.1, ec="black", fc="blue")) + +ax2.set_title("An Edited View") +# Ask ax2 to view ax1. +view(ax2, ax1, filter_set=[plt.Circle]) +ax2.set_xlim(0.33, 0.66) +ax2.set_ylim(0.33, 0.66) + +# Does not create a new view as ax2 is already viewing ax1. +# Edit ax2's viewing of ax1, remove filtering and disable line scaling. +view(ax2, ax1, filter_set=None, scale_lines=False) + +ax3.set_title("A Stopped View") +view(ax3, ax1) # Ask ax3 to view ax1. +ax3.set_xlim(0.33, 0.66) +ax3.set_ylim(0.33, 0.66) + +# This makes ax3 stop viewing ax1. +stop_viewing(ax3, ax1) + +fig.tight_layout() +fig.show() \ No newline at end of file diff --git a/matplotview/__init__.py b/matplotview/__init__.py index 9f21dc2..19c0c37 100644 --- a/matplotview/__init__.py +++ b/matplotview/__init__.py @@ -1,9 +1,35 @@ -from matplotview._view_axes import view_wrapper +from typing import Optional, Iterable, Type, Union +from matplotlib.artist import Artist +from matplotlib.axes import Axes +from matplotlib.transforms import Transform +from matplotview._view_axes import ( + view_wrapper, + ViewSpecification, + DEFAULT_RENDER_DEPTH +) +from matplotview._docs import dynamic_doc_string, get_interpolation_list_str -def view(axes, axes_to_view, image_interpolation="nearest"): + +__all__ = ["view", "stop_viewing", "inset_zoom_axes"] + + +@dynamic_doc_string( + render_depth=DEFAULT_RENDER_DEPTH, + interp_list=get_interpolation_list_str() +) +def view( + axes: Axes, + axes_to_view: Axes, + image_interpolation: str = "nearest", + render_depth: Optional[int] = None, + filter_set: Optional[Iterable[Union[Type[Artist], Artist]]] = None, + scale_lines: bool = True +) -> Axes: """ Convert an axes into a view of another axes, displaying the contents of - the second axes. + the second axes. If this axes is already viewing the passed axes (This + function is called twice with the same axes arguments) this function + will update the settings of the viewing instead of creating a new view. Parameters ---------- @@ -14,18 +40,96 @@ def view(axes, axes_to_view, image_interpolation="nearest"): The axes to display the contents of in the first axes, the 'viewed' axes. - image_interpolation: + image_interpolation: string, default of '{image_interpolation}' The image interpolation method to use when displaying scaled images - from the axes being viewed. Defaults to "nearest". Supported options - are 'antialiased', 'nearest', 'bilinear', 'bicubic', 'spline16', - 'spline36', 'hanning', 'hamming', 'hermite', 'kaiser', 'quadric', - 'catrom', 'gaussian', 'bessel', 'mitchell', 'sinc', 'lanczos', - or 'none' + from the axes being viewed. Defaults to '{image_interpolation}'. + Supported options are {interp_list}. + + render_depth: optional int, positive, defaults to None + The number of recursive draws allowed for this view, this can happen + if the view is a child of the axes (such as an inset axes) or if + two views point at each other. If None, uses the default render depth + of {render_depth}, unless the axes passed is already a view axes, in + which case the render depth the view already has will be used. + + filter_set: Iterable[Union[Type[Artist], Artist]] or None + An optional filter set, which can be used to select what artists + are drawn by the view. Any artists or artist types in the set are not + drawn. + + scale_lines: bool, defaults to {scale_lines} + Specifies if lines should be drawn thicker based on scaling in the + view. + + Returns + ------- + axes + The modified `~.axes.Axes` instance which is now a view. + The modification occurs in-place. + + See Also + -------- + matplotview.stop_viewing: Delete or stop an already constructed view. + matplotview.inset_zoom_axes: Convenience method for creating inset axes + that are views of the parent axes. """ - return view_wrapper(type(axes)).from_axes(axes, axes_to_view, image_interpolation) + view_obj = view_wrapper(type(axes)).from_axes(axes, render_depth) + view_obj.view_specifications[axes_to_view] = ViewSpecification( + image_interpolation, + filter_set, + scale_lines + ) + return view_obj -def inset_zoom_axes(axes, bounds, *, image_interpolation="nearest", transform=None, zorder=5, **kwargs): +def stop_viewing(view: Axes, axes_of_viewing: Axes) -> Axes: + """ + Terminate the viewing of a specified axes. + + Parameters + ---------- + view: Axes + The axes that is currently viewing the `axes_of_viewing`... + + axes_of_viewing: Axes + The axes that the view should stop viewing. + + Returns + ------- + view + The view, which has now been modified in-place. + + Raises + ------ + AttributeError + If the provided `axes_of_viewing` is not actually being + viewed by the specified view. + + See Also + -------- + matplotview.view: To create views. + """ + view = view_wrapper(type(view)).from_axes(view) + del view.view_specifications[axes_of_viewing] + return view + + +@dynamic_doc_string( + render_depth=DEFAULT_RENDER_DEPTH, + interp_list=get_interpolation_list_str() +) +def inset_zoom_axes( + axes: Axes, + bounds: Iterable, + *, + image_interpolation: str = "nearest", + render_depth: Optional[int] = None, + filter_set: Optional[Iterable[Union[Type[Artist], Artist]]] = None, + scale_lines: bool = True, + transform: Transform = None, + zorder: int = 5, + **kwargs +) -> Axes: """ Add a child inset Axes to an Axes, which automatically plots artists contained within the parent Axes. @@ -43,17 +147,30 @@ def inset_zoom_axes(axes, bounds, *, image_interpolation="nearest", transform=No Axes-relative coordinates. zorder: number - Defaults to 5 (same as `.Axes.legend`). Adjust higher or lower + Defaults to {zorder} (same as `.Axes.legend`). Adjust higher or lower to change whether it is above or below data plotted on the parent Axes. image_interpolation: string - Supported options are 'antialiased', 'nearest', 'bilinear', - 'bicubic', 'spline16', 'spline36', 'hanning', 'hamming', 'hermite', - 'kaiser', 'quadric', 'catrom', 'gaussian', 'bessel', 'mitchell', - 'sinc', 'lanczos', or 'none'. The default value is 'nearest'. This - determines the interpolation used when attempting to render a - zoomed version of an image. + Supported options are {interp_list}. The default value is + '{image_interpolation}'. This determines the interpolation + used when attempting to render a zoomed version of an image. + + render_depth: optional int, positive, defaults to None + The number of recursive draws allowed for this view, this can happen + if the view is a child of the axes (such as an inset axes) or if + two views point at each other. If None, uses the default render depth + of {render_depth}, unless the axes passed is already a view axes, + in which case the render depth the view already has will be used. + + filter_set: Iterable[Union[Type[Artist], Artist]] or None + An optional filter set, which can be used to select what artists + are drawn by the view. Any artists or artist types in the set are not + drawn. + + scale_lines: bool, defaults to {scale_lines} + Specifies if lines should be drawn thicker based on scaling in the + view. **kwargs Other keyword arguments are passed on to the child `.Axes`. @@ -63,11 +180,14 @@ def inset_zoom_axes(axes, bounds, *, image_interpolation="nearest", transform=No ax The created `~.axes.Axes` instance. - Examples + See Also -------- - See `Axes.inset_axes` method for examples. + matplotview.view: For creating views in generalized cases. """ inset_ax = axes.inset_axes( bounds, transform=transform, zorder=zorder, **kwargs ) - return view(inset_ax, axes, image_interpolation) + return view( + inset_ax, axes, image_interpolation, + render_depth, filter_set, scale_lines + ) diff --git a/matplotview/_docs.py b/matplotview/_docs.py new file mode 100644 index 0000000..5753df5 --- /dev/null +++ b/matplotview/_docs.py @@ -0,0 +1,23 @@ +import inspect + + +def dynamic_doc_string(**kwargs): + def convert(func): + default_vals = { + k: v.default for k, v in inspect.signature(func).parameters.items() + if (v.default is not inspect.Parameter.empty) + } + default_vals.update(kwargs) + func.__doc__ = func.__doc__.format(**default_vals) + + return func + + return convert + + +def get_interpolation_list_str(): + from matplotlib.image import _interpd_ + return ", ".join([ + f"'{k}'" if (i != len(_interpd_) - 1) else f"or '{k}'" + for i, k in enumerate(_interpd_) + ]) diff --git a/matplotview/_transform_renderer.py b/matplotview/_transform_renderer.py index 210f76a..4bfd180 100644 --- a/matplotview/_transform_renderer.py +++ b/matplotview/_transform_renderer.py @@ -1,9 +1,22 @@ -from matplotlib.backend_bases import RendererBase -from matplotlib.transforms import Bbox, IdentityTransform, Affine2D +from typing import Tuple, Union +from matplotlib.axes import Axes +from matplotlib.backend_bases import RendererBase, GraphicsContextBase +from matplotlib.font_manager import FontProperties +from matplotlib.patches import Rectangle +from matplotlib.texmanager import TexManager +from matplotlib.transforms import Bbox, IdentityTransform, Affine2D, \ + TransformedPatchPath, Transform from matplotlib.path import Path import matplotlib._image as _image import numpy as np from matplotlib.image import _interpd_ +from matplotview._docs import dynamic_doc_string, get_interpolation_list_str + +ColorTup = Union[ + None, + Tuple[float, float, float, float], + Tuple[float, float, float] +] class _TransformRenderer(RendererBase): @@ -13,14 +26,15 @@ class _TransformRenderer(RendererBase): original renderer. """ + @dynamic_doc_string(interp_list=get_interpolation_list_str()) def __init__( self, - base_renderer, - mock_transform, - transform, - bounding_axes, - image_interpolation="nearest", - scale_linewidths=True + base_renderer: RendererBase, + mock_transform: Transform, + transform: Transform, + bounding_axes: Axes, + image_interpolation: str = "nearest", + scale_linewidths: bool = True ): """ Constructs a new TransformRender. @@ -40,7 +54,7 @@ def __init__( transform: `~matplotlib.transforms.Transform` The main transform to be used for plotting all objects once - converted into the mock_transform coordinate space. Typically this + converted into the mock_transform coordinate space. Typically, this is the child axes data coordinate space (transData). bounding_axes: `~matplotlib.axes.Axes` @@ -48,14 +62,11 @@ def __init__( axes will be clipped. image_interpolation: string - Supported options are 'antialiased', 'nearest', 'bilinear', - 'bicubic', 'spline16', 'spline36', 'hanning', 'hamming', 'hermite', - 'kaiser', 'quadric', 'catrom', 'gaussian', 'bessel', 'mitchell', - 'sinc', 'lanczos', or 'none'. The default value is 'nearest'. This - determines the interpolation used when attempting to render a - zoomed version of an image. - - scale_linewidths: bool, default is True + Supported options are {interp_list}. The default value is + '{image_interpolation}'. This determines the interpolation + used when attempting to render a zoomed version of an image. + + scale_linewidths: bool, default is {scale_linewidths} Specifies if line widths should be scaled, in addition to the paths themselves. @@ -78,30 +89,38 @@ def __init__( f"Invalid Interpolation Mode: {image_interpolation}" ) - def _scale_gc(self, gc): - transfer_transform = self._get_transfer_transform(IdentityTransform()) - new_gc = self.__renderer.new_gc() - new_gc.copy_properties(gc) + @property + def bounding_axes(self) -> Axes: + return self.__bounding_axes + + def _scale_gc(self, gc: GraphicsContextBase) -> GraphicsContextBase: + with np.errstate(all='ignore'): + transfer_transform = self._get_transfer_transform( + IdentityTransform() + ) + new_gc = self.__renderer.new_gc() + new_gc.copy_properties(gc) + + unit_box = Bbox.from_bounds(0, 0, 1, 1) + unit_box = transfer_transform.transform_bbox(unit_box) + mult_factor = np.sqrt(unit_box.width * unit_box.height) - unit_box = Bbox.from_bounds(0, 0, 1, 1) - unit_box = transfer_transform.transform_bbox(unit_box) - mult_factor = np.sqrt(unit_box.width * unit_box.height) + if (mult_factor == 0 or (not np.isfinite(mult_factor))): + return new_gc - new_gc.set_linewidth(gc.get_linewidth() * mult_factor) - new_gc._hatch_linewidth = gc.get_hatch_linewidth() * mult_factor + new_gc.set_linewidth(gc.get_linewidth() * mult_factor) + new_gc._hatch_linewidth = gc.get_hatch_linewidth() * mult_factor - return new_gc + return new_gc - def _get_axes_display_box(self): + def _get_axes_display_box(self) -> Bbox: """ Private method, get the bounding box of the child axes in display coordinates. """ - return self.__bounding_axes.patch.get_bbox().transformed( - self.__bounding_axes.transAxes - ) + return self.__bounding_axes.get_window_extent() - def _get_transfer_transform(self, orig_transform): + def _get_transfer_transform(self, orig_transform: Transform) -> Transform: """ Private method, returns the transform which translates and scales coordinates as if they were originally plotted on the child axes @@ -132,43 +151,63 @@ def _get_transfer_transform(self, orig_transform): # We copy all of the properties of the renderer we are mocking, so that # artists plot themselves as if they were placed on the original renderer. @property - def height(self): + def height(self) -> int: return self.__renderer.get_canvas_width_height()[1] @property - def width(self): + def width(self) -> int: return self.__renderer.get_canvas_width_height()[0] - def get_text_width_height_descent(self, s, prop, ismath): + def get_text_width_height_descent( + self, + s: str, + prop: FontProperties, + ismath: bool + ) -> Tuple[float, float, float]: return self.__renderer.get_text_width_height_descent(s, prop, ismath) - def get_canvas_width_height(self): + def get_canvas_width_height(self) -> Tuple[float, float]: return self.__renderer.get_canvas_width_height() - def get_texmanager(self): + def get_texmanager(self) -> TexManager: return self.__renderer.get_texmanager() - def get_image_magnification(self): + def get_image_magnification(self) -> float: return self.__renderer.get_image_magnification() - def _get_text_path_transform(self, x, y, s, prop, angle, ismath): - return self.__renderer._get_text_path_transform(x, y, s, prop, angle, - ismath) + def _get_text_path_transform( + self, + x: float, + y: float, + s: str, + prop: FontProperties, + angle: float, + ismath: bool + ) -> Transform: + return self.__renderer._get_text_path_transform( + x, y, s, prop, angle, ismath + ) - def option_scale_image(self): + def option_scale_image(self) -> bool: return False - def points_to_pixels(self, points): + def points_to_pixels(self, points: float) -> float: return self.__renderer.points_to_pixels(points) - def flipy(self): + def flipy(self) -> bool: return self.__renderer.flipy() - def new_gc(self): + def new_gc(self) -> GraphicsContextBase: return self.__renderer.new_gc() # Actual drawing methods below: - def draw_path(self, gc, path, transform, rgbFace=None): + def draw_path( + self, + gc: GraphicsContextBase, + path: Path, + transform: Transform, + rgbFace: ColorTup = None + ): # Convert the path to display coordinates, but if it was originally # drawn on the child axes. path = path.deepcopy() @@ -179,46 +218,144 @@ def draw_path(self, gc, path, transform, rgbFace=None): # We check if the path intersects the axes box at all, if not don't # waste time drawing it. - if(not path.intersects_bbox(bbox, True)): + if (not path.intersects_bbox(bbox, True)): return - if(self.__scale_widths): + if (self.__scale_widths): gc = self._scale_gc(gc) # Change the clip to the sub-axes box gc.set_clip_rectangle(bbox) + if (not isinstance(self.__bounding_axes.patch, Rectangle)): + gc.set_clip_path(TransformedPatchPath(self.__bounding_axes.patch)) + + rgbFace = tuple(rgbFace) if (rgbFace is not None) else None self.__renderer.draw_path(gc, path, IdentityTransform(), rgbFace) - def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath): + def _draw_text_as_path( + self, + gc: GraphicsContextBase, + x: float, + y: float, + s: str, + prop: FontProperties, + angle: float, + ismath: bool + ): # If the text field is empty, don't even try rendering it... - if((s is None) or (s.strip() == "")): + if ((s is None) or (s.strip() == "")): return # Call the super class instance, which works for all cases except one # checked above... (Above case causes error) super()._draw_text_as_path(gc, x, y, s, prop, angle, ismath) - def draw_gouraud_triangle(self, gc, points, colors, transform): + def draw_markers( + self, + gc, + marker_path, + marker_trans, + path, + trans, + rgbFace=None, + ): + # If the markers need to be scaled accurately (such as in log scale), just use the fallback as each will need + # to be scaled separately. + if (self.__scale_widths): + super().draw_markers(gc, marker_path, marker_trans, path, trans, rgbFace) + return + + # Otherwise we transform just the marker offsets (not the marker patch), so they stay the same size. + path = path.deepcopy() + path.vertices = self._get_transfer_transform(trans).transform(path.vertices) + bbox = self._get_axes_display_box() + + # Change the clip to the sub-axes box + gc.set_clip_rectangle(bbox) + if (not isinstance(self.__bounding_axes.patch, Rectangle)): + gc.set_clip_path(TransformedPatchPath(self.__bounding_axes.patch)) + + rgbFace = tuple(rgbFace) if (rgbFace is not None) else None + self.__renderer.draw_markers(gc, marker_path, marker_trans, path, IdentityTransform(), rgbFace) + + def draw_path_collection( + self, + gc, + master_transform, + paths, + all_transforms, + offsets, + offset_trans, + facecolors, + edgecolors, + linewidths, + linestyles, + antialiaseds, + urls, + offset_position, + ): + # If we want accurate scaling for each marker (such as in log scale), just use superclass implementation... + if (self.__scale_widths): + super().draw_path_collection( + gc, master_transform, paths, all_transforms, offsets, offset_trans, facecolors, + edgecolors, linewidths, linestyles, antialiaseds, urls, offset_position + ) + return + + # Otherwise we transform just the offsets, and pass them to the backend. + print(offsets) + if (np.any(np.isnan(offsets))): + raise ValueError("???") + offsets = self._get_transfer_transform(offset_trans).transform(offsets) + print(offsets) + bbox = self._get_axes_display_box() + + # Change the clip to the sub-axes box + gc.set_clip_rectangle(bbox) + if (not isinstance(self.__bounding_axes.patch, Rectangle)): + gc.set_clip_path(TransformedPatchPath(self.__bounding_axes.patch)) + + self.__renderer.draw_path_collection( + gc, master_transform, paths, all_transforms, offsets, IdentityTransform(), facecolors, + edgecolors, linewidths, linestyles, antialiaseds, urls, None + ) + + def draw_gouraud_triangle( + self, + gc: GraphicsContextBase, + points: np.ndarray, + colors: np.ndarray, + transform: Transform + ): # Pretty much identical to draw_path, transform the points and adjust # clip to the child axes bounding box. points = self._get_transfer_transform(transform).transform(points) path = Path(points, closed=True) bbox = self._get_axes_display_box() - if(not path.intersects_bbox(bbox, True)): + if (not path.intersects_bbox(bbox, True)): return - if(self.__scale_widths): + if (self.__scale_widths): gc = self._scale_gc(gc) gc.set_clip_rectangle(bbox) + if (not isinstance(self.__bounding_axes.patch, Rectangle)): + gc.set_clip_path(TransformedPatchPath(self.__bounding_axes.patch)) self.__renderer.draw_gouraud_triangle(gc, path.vertices, colors, IdentityTransform()) # Images prove to be especially messy to deal with... - def draw_image(self, gc, x, y, im, transform=None): + def draw_image( + self, + gc: GraphicsContextBase, + x: float, + y: float, + im: np.ndarray, + transform: Transform = None + ): mag = self.get_image_magnification() shift_data_transform = self._get_transfer_transform( IdentityTransform() @@ -232,7 +369,7 @@ def draw_image(self, gc, x, y, im, transform=None): out_box = img_bbox_disp.transformed(shift_data_transform) clipped_out_box = Bbox.intersection(out_box, axes_bbox) - if(clipped_out_box is None): + if (clipped_out_box is None): return # We compute what the dimensions of the final output image within the @@ -240,7 +377,7 @@ def draw_image(self, gc, x, y, im, transform=None): x, y, out_w, out_h = clipped_out_box.bounds out_w, out_h = int(np.ceil(out_w * mag)), int(np.ceil(out_h * mag)) - if((out_w <= 0) or (out_h <= 0)): + if ((out_w <= 0) or (out_h <= 0)): return # We can now construct the transform which converts between the @@ -263,15 +400,16 @@ def draw_image(self, gc, x, y, im, transform=None): alpha=1) out_arr[:, :, 3] = trans_msk - if(self.__scale_widths): + if (self.__scale_widths): gc = self._scale_gc(gc) gc.set_clip_rectangle(clipped_out_box) + if (not isinstance(self.__bounding_axes.patch, Rectangle)): + gc.set_clip_path(TransformedPatchPath(self.__bounding_axes.patch)) x, y = clipped_out_box.x0, clipped_out_box.y0 - if(self.option_scale_image()): + if (self.option_scale_image()): self.__renderer.draw_image(gc, x, y, out_arr, None) else: self.__renderer.draw_image(gc, x, y, out_arr) - diff --git a/matplotview/_view_axes.py b/matplotview/_view_axes.py index de1adf9..f0b47a9 100644 --- a/matplotview/_view_axes.py +++ b/matplotview/_view_axes.py @@ -1,25 +1,41 @@ +import functools import itertools -from typing import Type, List +from typing import Type, List, Optional, Any, Set, Dict, Union from matplotlib.axes import Axes from matplotlib.transforms import Bbox -import matplotlib.docstring as docstring from matplotview._transform_renderer import _TransformRenderer from matplotlib.artist import Artist from matplotlib.backend_bases import RendererBase +from dataclasses import dataclass +from matplotview._docs import dynamic_doc_string, get_interpolation_list_str -class BoundRendererArtist: - def __init__(self, artist: Artist, renderer: RendererBase, clip_box: Bbox): +DEFAULT_RENDER_DEPTH = 5 + + +class _BoundRendererArtist: + """ + Provides a temporary wrapper around a given artist, inheriting its + attributes and values, while overriding the draw method to use a fixed + TransformRenderer. This is used to render an artist to a view without + having to implement a new draw method for every Axes type. + """ + def __init__( + self, + artist: Artist, + renderer: _TransformRenderer, + clip_box: Bbox + ): self._artist = artist self._renderer = renderer self._clip_box = clip_box - def __getattribute__(self, item): + def __getattribute__(self, item: str) -> Any: try: return super().__getattribute__(item) except AttributeError: return self._artist.__getattribute__(item) - def __setattr__(self, key, value): + def __setattr__(self, key: str, value: Any): try: super().__setattr__(key, value) except AttributeError: @@ -29,23 +45,109 @@ def draw(self, renderer: RendererBase): # Disable the artist defined clip box, as the artist might be visible # under the new renderer even if not on screen... clip_box_orig = self._artist.get_clip_box() + clip_path_orig = self._artist.get_clip_path() + full_extents = self._artist.get_window_extent(self._renderer) - self._artist.set_clip_box(full_extents) + self._artist.set_clip_box(None) + self._artist.set_clip_path(None) + + # If we are working with a 3D object, swap out it's axes with + # this zoom axes (swapping out the 3d transform) and reproject it. + if (hasattr(self._artist, "do_3d_projection")): + self.do_3d_projection() # Check and see if the passed limiting box and extents of the # artist intersect, if not don't bother drawing this artist. - if(Bbox.intersection(full_extents, self._clip_box) is not None): + # First 2 checks are a special case where we received a bad clip box. + # (those can happen when we try to get the bounds of a map projection) + if ( + self._clip_box.width == 0 or self._clip_box.height == 0 or + Bbox.intersection(full_extents, self._clip_box) is not None + ): self._artist.draw(self._renderer) - # Re-enable the clip box... + # Re-enable the clip box... and clip path... self._artist.set_clip_box(clip_box_orig) + self._artist.set_clip_path(clip_path_orig) + + def do_3d_projection(self) -> float: + # Get the 3D projection function... + do_3d_projection = getattr(self._artist, "do_3d_projection") + + # Intentionally replace the axes of the artist with the view axes, + # as the do_3d_projection pulls the 3D transform (M) from the axes. + # Then reproject, and restore the original axes. + ax = self._artist.axes + self._artist.axes = None # Set to None first to avoid exception... + self._artist.axes = self._renderer.bounding_axes + res = do_3d_projection() # Returns a z-order value... + self._artist.axes = None + self._artist.axes = ax + + return res + + +def _view_from_pickle(builder, args): + """ + PRIVATE: Construct a View wrapper axes given an axes builder and class. + """ + res = builder(*args) + res.__class__ = view_wrapper(type(res)) + return res +@dynamic_doc_string( + render_depth=DEFAULT_RENDER_DEPTH, + interp_list=get_interpolation_list_str() +) +@dataclass +class ViewSpecification: + """ + A view specification, or a mutable dataclass containing configuration + options for a view's "viewing" of a different axes. + + Attributes + ---------- + image_interpolation: string + Supported options are {interp_list}. The default value is + '{image_interpolation}'. This determines the interpolation + used when attempting to render a zoomed version of an image. + + filter_set: Iterable[Union[Type[Artist], Artist]] or {filter_set} + An optional filter set, which can be used to select what artists + are drawn by the view. Any artists or artist types in the set are not + drawn. + + scale_lines: bool, defaults to {scale_lines} + Specifies if lines should be drawn thicker based on scaling in the + view. + """ + image_interpolation: str = "nearest" + filter_set: Optional[Set[Union[Type[Artist], Artist]]] = None + scale_lines: bool = True + + def __post_init__(self): + self.image_interpolation = str(self.image_interpolation) + if (self.filter_set is not None): + self.filter_set = set(self.filter_set) + self.scale_lines = bool(self.scale_lines) + + +class __ViewType: + """ + PRIVATE: A simple identifier class for identifying view types, a view + will inherit from the axes class it is wrapping and this type... + """ + + +# Cache classes so grabbing the same type twice leads to actually getting the +# same type (and type comparisons work). +@functools.lru_cache(None) def view_wrapper(axes_class: Type[Axes]) -> Type[Axes]: """ - Construct a ViewAxes, which subclasses, or wraps a specific Axes subclass. - A ViewAxes can be configured to display the contents of another Axes - within the same Figure. + Construct a View axes, which subclasses, or wraps a specific Axes subclass. + A View axes can be configured to display the contents of other Axes + (plural) within the same Figure. Parameters ---------- @@ -54,26 +156,25 @@ def view_wrapper(axes_class: Type[Axes]) -> Type[Axes]: Returns ------- - ViewAxes: - The view axes wrapper for a given axes class, capable of display - other axes contents... + View[axes_class]: + The view axes wrapper for a given axes class, capable of displaying + another axes contents... """ + # If the passed class is a view, simply return it. + if (issubclass(axes_class, Axes) and issubclass(axes_class, __ViewType)): + return axes_class - @docstring.interpd - class ViewAxesImpl(axes_class): + class View(axes_class, __ViewType): """ An axes which automatically displays elements of another axes. Does not require Artists to be plotted twice. """ - __module__ = axes_class.__module__ - # The number of allowed recursions in the draw method - MAX_RENDER_DEPTH = 5 + @dynamic_doc_string() def __init__( self, - axes_to_view: Axes, *args, - image_interpolation: str = "nearest", + render_depth: int = DEFAULT_RENDER_DEPTH, **kwargs ): """ @@ -81,83 +182,88 @@ def __init__( Parameters ---------- - axes_to_view: `~.axes.Axes` - The axes to create a view of. - *args Additional arguments to be passed to the Axes class this ViewAxes wraps. - image_interpolation: string - Supported options are 'antialiased', 'nearest', 'bilinear', - 'bicubic', 'spline16', 'spline36', 'hanning', 'hamming', - 'hermite', 'kaiser', 'quadric', 'catrom', 'gaussian', 'bessel', - 'mitchell', 'sinc', 'lanczos', or 'none'. The default value is - 'nearest'. This determines the interpolation used when - attempting to render a view of an image. + render_depth: int, positive, defaults to {render_depth} + The number of recursive draws allowed for this view, this can + happen if the view is a child of the axes (such as an inset + axes) or if two views point at each other. Defaults to + {render_depth}. **kwargs Other optional keyword arguments supported by the Axes - constructor this ViewAxes wraps: - - %(Axes:kwdoc)s + constructor this ViewAxes wraps. Returns ------- - ViewAxes + View The new zoom view axes instance... """ - super().__init__(axes_to_view.figure, *args, **kwargs) - self._init_vars(axes_to_view, image_interpolation) + super().__init__(*args, **kwargs) + self._init_vars(render_depth) - def _init_vars( - self, - axes_to_view: Axes, - image_interpolation: str = "nearest" - ): - self.__view_axes = axes_to_view - self.__image_interpolation = image_interpolation - self._render_depth = 0 - self.__scale_lines = True + def _init_vars(self, render_depth: int = DEFAULT_RENDER_DEPTH): + # Initialize the view specs dict... + self.__view_specs = getattr(self, "__view_specs", {}) self.__renderer = None + self.__max_render_depth = getattr( + self, "__max_render_depth", DEFAULT_RENDER_DEPTH + ) + self.set_max_render_depth(render_depth) + # The current render depth is stored in the figure, so the number + # of recursive draws is even in the case of multiple axes drawing + # each other in the same figure. + self.figure._current_render_depth = getattr( + self.figure, "_current_render_depth", 0 + ) def get_children(self) -> List[Artist]: # We overload get_children to return artists from the view axes # in addition to this axes when drawing. We wrap the artists # in a BoundRendererArtist, so they are drawn with an alternate # renderer, and therefore to the correct location. - if(self.__renderer is not None): - mock_renderer = _TransformRenderer( - self.__renderer, self.__view_axes.transData, - self.transData, self, self.__image_interpolation, - self.__scale_lines + child_list = super().get_children() + + def filter_check(artist, filter_set): + if (filter_set is None): + return True + return ( + (artist not in filter_set) + and (type(artist) not in filter_set) ) - x1, x2 = self.get_xlim() - y1, y2 = self.get_ylim() - axes_box = Bbox.from_extents(x1, y1, x2, y2).transformed( - self.__view_axes.transData - ) + if (self.__renderer is not None): + for ax, spec in self.view_specifications.items(): + mock_renderer = _TransformRenderer( + self.__renderer, ax.transData, self.transData, + self, spec.image_interpolation, spec.scale_lines + ) + + x1, x2 = self.get_xlim() + y1, y2 = self.get_ylim() + axes_box = Bbox.from_extents(x1, y1, x2, y2).transformed( + ax.transData + ) + + child_list.extend([ + _BoundRendererArtist(a, mock_renderer, axes_box) + for a in itertools.chain( + ax._children, + ax.child_axes + ) if (filter_check(a, spec.filter_set)) + ]) + + return child_list - init_list = super().get_children() - init_list.extend([ - BoundRendererArtist(a, mock_renderer, axes_box) - for a in itertools.chain( - self.__view_axes._children, self.__view_axes.child_axes - ) if(a is not self) - ]) - - return init_list - else: - return super().get_children() - def draw(self, renderer: RendererBase = None): # It is possible to have two axes which are views of each other # therefore we track the number of recursions and stop drawing # at a certain depth - if(self._render_depth >= self.MAX_RENDER_DEPTH): + if (self.figure._current_render_depth >= self.__max_render_depth): return - self._render_depth += 1 + self.figure._current_render_depth += 1 # Set the renderer, causing get_children to return the view's # children also... self.__renderer = renderer @@ -166,47 +272,128 @@ def draw(self, renderer: RendererBase = None): # Get rid of the renderer... self.__renderer = None - self._render_depth -= 1 + self.figure._current_render_depth -= 1 + + def __reduce__(self): + builder, args = super().__reduce__()[:2] + + cls = type(self) + + args = tuple( + arg if (arg != cls) else cls.__bases__[0] for arg in args + ) + + return ( + _view_from_pickle, + (builder, args), + self.__getstate__() + ) + + def __getstate__(self): + state = super().__getstate__() + state["__renderer"] = None + return state - def get_linescaling(self) -> bool: + def get_max_render_depth(self) -> int: """ - Get if line width scaling is enabled. + Get the max recursive rendering depth for this view axes. Returns ------- - bool - If line width scaling is enabled returns True, otherwise False. + int + A positive non-zero integer, the number of recursive redraws + this view axes will allow. """ - return self.__scale_lines + return self.__max_render_depth - def set_linescaling(self, value: bool): + def set_max_render_depth(self, val: int): """ - Set whether line widths should be scaled when rendering a view of - an axes. + Set the max recursive rendering depth for this view axes. Parameters ---------- - value: bool - If true, scale line widths in the view to match zoom level. - Otherwise don't. + val: int + The number of recursive draws of views this view axes will + allow. Zero and negative values are invalid, and will raise a + ValueError. """ - self.__scale_lines = value + if (val <= 0): + raise ValueError(f"Render depth must be positive, not {val}.") + self.__max_render_depth = val + + @property + def view_specifications(self) -> Dict[Axes, ViewSpecification]: + """ + Get the current view specifications of this view axes. + + Returns + ------- + Dict[Axes, ViewSpecification] + A dictionary of Axes to ViewSpecification objects, listing + all the axes this view looks at and the settings for each + viewing. + """ + return self.__view_specs + + # Shortcut for easier access... + view_specs = view_specifications @classmethod + @dynamic_doc_string(render_depth=DEFAULT_RENDER_DEPTH) def from_axes( cls, axes: Axes, - axes_to_view: Axes, - image_interpolation: str = "nearest" - ): - axes.__class__ = cls - axes._init_vars(axes_to_view, image_interpolation) - return axes + render_depth: Optional[int] = None + ) -> Axes: + """ + Convert an Axes into a View in-place. This is used by public + APIs to construct views, and using this method directly + is not recommended. Instead, use `view` which resolves types + and settings automatically. + + Parameters + ---------- + axes: Axes + The axes to convert to a view wrapping the same axes type. - new_name = f"{ViewAxesImpl.__name__}[{axes_class.__name__}]" - ViewAxesImpl.__name__ = ViewAxesImpl.__qualname__ = new_name + render_depth: optional int, positive, defaults to None + The number of recursive draws allowed for this view, this can + happen if the view is a child of the axes (such as an inset + axes) or if two views point at each other. If none, use the + default value ({render_depth}) if the render depth is not + already set. - return ViewAxesImpl + Returns + ------- + View + The same axes passed in, which is now a View type which wraps + the axes original type (View[axes_original_class]). + + Raises + ------ + TypeError + If the provided axes to convert has an Axes type which does + not match the axes class this view type wraps. + """ + if (isinstance(axes, cls)): + if (render_depth is not None): + axes.set_max_render_depth(render_depth) + return axes + + if (type(axes) is not axes_class): + raise TypeError( + f"Can't convert {type(axes).__name__} to {cls.__name__}" + ) + + axes.__class__ = cls + axes._init_vars( + DEFAULT_RENDER_DEPTH + if (render_depth is None) + else render_depth + ) + return axes + View.__name__ = f"{View.__name__}[{axes_class.__name__}]" + View.__qualname__ = f"{View.__qualname__}[{axes_class.__name__}]" -ViewAxes = view_wrapper(Axes) \ No newline at end of file + return View diff --git a/matplotview/tests/test_inset_zoom.py b/matplotview/tests/test_inset_zoom.py deleted file mode 100644 index a92122e..0000000 --- a/matplotview/tests/test_inset_zoom.py +++ /dev/null @@ -1,107 +0,0 @@ -import numpy as np -import matplotlib.pyplot as plt -from matplotlib.testing.decorators import check_figures_equal -from matplotview import view, inset_zoom_axes - -@check_figures_equal(tol=6) -def test_double_plot(fig_test, fig_ref): - np.random.seed(1) - im_data = np.random.rand(30, 30) - - # Test case... - ax_test1, ax_test2 = fig_test.subplots(1, 2) - - ax_test1.plot([i for i in range(10)], "r") - ax_test1.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) - ax_test1.text(10, 10, "Hello World!", size=14) - ax_test1.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, - interpolation="nearest") - ax_test2 = view(ax_test2, ax_test1) - ax_test2.set_aspect(ax_test1.get_aspect()) - ax_test2.set_xlim(ax_test1.get_xlim()) - ax_test2.set_ylim(ax_test1.get_ylim()) - - # Reference... - ax_ref1, ax_ref2 = fig_ref.subplots(1, 2) - - ax_ref1.plot([i for i in range(10)], "r") - ax_ref1.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) - ax_ref1.text(10, 10, "Hello World!", size=14) - ax_ref1.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, - interpolation="nearest") - ax_ref2.plot([i for i in range(10)], "r") - ax_ref2.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) - ax_ref2.text(10, 10, "Hello World!", size=14) - ax_ref2.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, - interpolation="nearest") - - -# Tolerance needed because the way the auto-zoom axes handles images is -# entirely different, leading to a slightly different result. -@check_figures_equal(tol=3.5) -def test_auto_zoom_inset(fig_test, fig_ref): - np.random.seed(1) - im_data = np.random.rand(30, 30) - - # Test Case... - ax_test = fig_test.gca() - ax_test.plot([i for i in range(10)], "r") - ax_test.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) - ax_test.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, - interpolation="nearest") - axins_test = inset_zoom_axes(ax_test, [0.5, 0.5, 0.48, 0.48]) - axins_test.set_linescaling(False) - axins_test.set_xlim(1, 5) - axins_test.set_ylim(1, 5) - ax_test.indicate_inset_zoom(axins_test, edgecolor="black") - - # Reference - ax_ref = fig_ref.gca() - ax_ref.plot([i for i in range(10)], "r") - ax_ref.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) - ax_ref.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, - interpolation="nearest") - axins_ref = ax_ref.inset_axes([0.5, 0.5, 0.48, 0.48]) - axins_ref.set_xlim(1, 5) - axins_ref.set_ylim(1, 5) - axins_ref.plot([i for i in range(10)], "r") - axins_ref.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) - axins_ref.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, - interpolation="nearest") - ax_ref.indicate_inset_zoom(axins_ref, edgecolor="black") - - -@check_figures_equal(tol=3.5) -def test_plotting_in_view(fig_test, fig_ref): - np.random.seed(1) - im_data = np.random.rand(30, 30) - arrow_s = dict(arrowstyle="->") - - # Test Case... - ax_test = fig_test.gca() - ax_test.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, - interpolation="nearest") - axins_test = inset_zoom_axes(ax_test, [0.5, 0.5, 0.48, 0.48]) - axins_test.set_linescaling(False) - axins_test.set_xlim(1, 5) - axins_test.set_ylim(1, 5) - axins_test.annotate( - "Interesting", (3, 3), (0, 0), - textcoords="axes fraction", arrowprops=arrow_s - ) - ax_test.indicate_inset_zoom(axins_test, edgecolor="black") - - # Reference - ax_ref = fig_ref.gca() - ax_ref.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, - interpolation="nearest") - axins_ref = ax_ref.inset_axes([0.5, 0.5, 0.48, 0.48]) - axins_ref.set_xlim(1, 5) - axins_ref.set_ylim(1, 5) - axins_ref.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, - interpolation="nearest") - axins_ref.annotate( - "Interesting", (3, 3), (0, 0), - textcoords="axes fraction", arrowprops=arrow_s - ) - ax_ref.indicate_inset_zoom(axins_ref, edgecolor="black") \ No newline at end of file diff --git a/matplotview/tests/test_view_obj.py b/matplotview/tests/test_view_obj.py new file mode 100644 index 0000000..c356664 --- /dev/null +++ b/matplotview/tests/test_view_obj.py @@ -0,0 +1,160 @@ +import matplotlib.pyplot as plt +from matplotlib.testing.decorators import check_figures_equal +from matplotview.tests.utils import plotting_test, matches_post_pickle +from matplotview import view, inset_zoom_axes, ViewSpecification +from matplotview._view_axes import DEFAULT_RENDER_DEPTH, view_wrapper +import numpy as np + + +def test_obj_comparison(): + from matplotlib.axes import Subplot, Axes + import matplotlib + + mpl_version = tuple(int(v) for v in matplotlib.__version__.split(".")[:2]) + + view_class1 = view_wrapper(Subplot) + view_class2 = view_wrapper(Subplot) + view_class3 = view_wrapper(Axes) + + assert view_class1 is view_class2 + assert view_class1 == view_class2 + if (mpl_version >= (3, 7)): + # As of 3.7.0, the subplot class no longer exists, and is an alias + # to the Axes class... + assert view_class2 == view_class3 + else: + assert view_class2 != view_class3 + + +@check_figures_equal(tol=5.6) +def test_getters_and_setters(fig_test, fig_ref): + np.random.seed(1) + im_data1 = np.random.rand(30, 30) + im_data2 = np.random.rand(20, 20) + + ax1, ax2, ax3 = fig_test.subplots(1, 3) + ax1.imshow(im_data1, origin="lower", interpolation="nearest") + ax2.imshow(im_data2, origin="lower", interpolation="nearest") + ax2.plot([i for i in range(10)]) + line = ax2.plot([i for i in range(10, 0, -1)])[0] + view(ax3, ax1) + ax3.set_xlim(0, 30) + ax3.set_ylim(0, 30) + ax3.set_aspect(1) + + # Assert all getters return default or set values... + assert ax1 in ax3.view_specifications + assert ax3.view_specifications[ax1].image_interpolation == "nearest" + assert ax3.get_max_render_depth() == DEFAULT_RENDER_DEPTH + assert ax3.view_specifications[ax1].scale_lines is True + assert ax3.view_specifications[ax1].filter_set is None + + # Attempt setting to different values... + del ax3.view_specifications[ax1] + # If this doesn't change pdf backend gets error > 5.6.... + ax3.view_specifications[ax2] = ViewSpecification( + "bicubic", + {line}, + False + ) + ax3.set_max_render_depth(10) + + # Compare against new thing... + ax1, ax2, ax3 = fig_ref.subplots(1, 3) + ax1.imshow(im_data1, origin="lower", interpolation="nearest") + ax2.imshow(im_data2, origin="lower", interpolation="nearest") + ax2.plot([i for i in range(10)]) + ax2.plot([i for i in range(10, 0, -1)]) + ax3.imshow(im_data2, origin="lower", interpolation="nearest") + ax3.plot([i for i in range(10)]) + ax3.set_xlim(0, 30) + ax3.set_ylim(0, 30) + + +@plotting_test() +def test_subplot_view_pickle(fig_test): + np.random.seed(1) + im_data = np.random.rand(30, 30) + + # Test case... + ax_test1, ax_test2 = fig_test.subplots(1, 2) + + ax_test1.plot([i for i in range(10)], "r") + ax_test1.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) + ax_test1.text(10, 10, "Hello World!", size=14) + ax_test1.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, + interpolation="nearest") + ax_test2 = view(ax_test2, ax_test1) + ax_test2.set_aspect(ax_test1.get_aspect()) + ax_test2.set_xlim(ax_test1.get_xlim()) + ax_test2.set_ylim(ax_test1.get_ylim()) + + assert matches_post_pickle(fig_test) + + +@plotting_test() +def test_zoom_plot_pickle(fig_test): + np.random.seed(1) + im_data = np.random.rand(30, 30) + arrow_s = dict(arrowstyle="->") + + # Test Case... + ax_test = fig_test.gca() + ax_test.plot([i for i in range(10)], "r") + ax_test.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) + ax_test.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, + interpolation="nearest") + axins_test = inset_zoom_axes(ax_test, [0.5, 0.5, 0.48, 0.48], + scale_lines=False) + axins_test.set_xlim(1, 5) + axins_test.set_ylim(1, 5) + axins_test.annotate( + "Interesting", (3, 3), (0, 0), + textcoords="axes fraction", arrowprops=arrow_s + ) + ax_test.indicate_inset_zoom(axins_test, edgecolor="black") + + assert matches_post_pickle(fig_test) + + +@plotting_test() +def test_3d_view_pickle(fig_test): + X = Y = np.arange(-5, 5, 0.25) + X, Y = np.meshgrid(X, Y) + Z = np.sin(np.sqrt(X ** 2 + Y ** 2)) + + ax1_test, ax2_test = fig_test.subplots( + 1, 2, subplot_kw=dict(projection="3d") + ) + ax1_test.plot_surface(X, Y, Z, cmap="plasma") + view(ax2_test, ax1_test) + ax2_test.view_init(elev=80) + ax2_test.set_xlim(-10, 10) + ax2_test.set_ylim(-10, 10) + ax2_test.set_zlim(-2, 2) + + assert matches_post_pickle(fig_test) + + +@plotting_test() +def test_multiplot_pickle(fig_test): + ax_test1, ax_test2, ax_test3 = fig_test.subplots(1, 3) + + ax_test1.add_patch(plt.Circle((1, 1), 1.5, ec="black", fc=(0, 0, 1, 0.5))) + ax_test3.add_patch(plt.Circle((3, 1), 1.5, ec="black", fc=(1, 0, 0, 0.5))) + + for ax in (ax_test1, ax_test3): + ax.set_aspect(1) + ax.relim() + ax.autoscale_view() + + ax_test2 = view( + view(ax_test2, ax_test1, scale_lines=False), + ax_test3, scale_lines=False + ) + + ax_test2.set_aspect(1) + ax_test2.set_xlim(-0.5, 4.5) + ax_test2.set_ylim(-0.5, 2.5) + + assert matches_post_pickle(fig_test) diff --git a/matplotview/tests/test_view_rendering.py b/matplotview/tests/test_view_rendering.py new file mode 100644 index 0000000..05d44e1 --- /dev/null +++ b/matplotview/tests/test_view_rendering.py @@ -0,0 +1,318 @@ +import sys + +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.testing.decorators import check_figures_equal +from matplotview import view, inset_zoom_axes, stop_viewing + + +@check_figures_equal(tol=6) +def test_double_plot(fig_test, fig_ref): + np.random.seed(1) + im_data = np.random.rand(30, 30) + + # Test case... + ax_test1, ax_test2 = fig_test.subplots(1, 2) + + ax_test1.plot([i for i in range(10)], "r") + ax_test1.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) + ax_test1.text(10, 10, "Hello World!", size=14) + ax_test1.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, + interpolation="nearest") + ax_test2 = view(ax_test2, ax_test1) + ax_test2.set_aspect(ax_test1.get_aspect()) + ax_test2.set_xlim(ax_test1.get_xlim()) + ax_test2.set_ylim(ax_test1.get_ylim()) + + # Reference... + ax_ref1, ax_ref2 = fig_ref.subplots(1, 2) + + ax_ref1.plot([i for i in range(10)], "r") + ax_ref1.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) + ax_ref1.text(10, 10, "Hello World!", size=14) + ax_ref1.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, + interpolation="nearest") + ax_ref2.plot([i for i in range(10)], "r") + ax_ref2.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) + ax_ref2.text(10, 10, "Hello World!", size=14) + ax_ref2.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, + interpolation="nearest") + + +# Tolerance needed because the way the auto-zoom axes handles images is +# entirely different, leading to a slightly different result. +@check_figures_equal(tol=3.5) +def test_auto_zoom_inset(fig_test, fig_ref): + np.random.seed(1) + im_data = np.random.rand(30, 30) + + # Test Case... + ax_test = fig_test.gca() + ax_test.plot([i for i in range(10)], "r") + ax_test.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) + ax_test.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, + interpolation="nearest") + axins_test = inset_zoom_axes(ax_test, [0.5, 0.5, 0.48, 0.48], + scale_lines=False) + axins_test.set_xlim(1, 5) + axins_test.set_ylim(1, 5) + ax_test.indicate_inset_zoom(axins_test, edgecolor="black") + + # Reference + ax_ref = fig_ref.gca() + ax_ref.plot([i for i in range(10)], "r") + ax_ref.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) + ax_ref.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, + interpolation="nearest") + axins_ref = ax_ref.inset_axes([0.5, 0.5, 0.48, 0.48]) + axins_ref.set_xlim(1, 5) + axins_ref.set_ylim(1, 5) + axins_ref.plot([i for i in range(10)], "r") + axins_ref.add_patch(plt.Circle((3, 3), 1, ec="black", fc="blue")) + axins_ref.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, + interpolation="nearest") + ax_ref.indicate_inset_zoom(axins_ref, edgecolor="black") + + +@check_figures_equal(tol=3.5) +def test_plotting_in_view(fig_test, fig_ref): + np.random.seed(1) + im_data = np.random.rand(30, 30) + arrow_s = dict(arrowstyle="->") + + # Test Case... + ax_test = fig_test.gca() + ax_test.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, + interpolation="nearest") + axins_test = inset_zoom_axes(ax_test, [0.5, 0.5, 0.48, 0.48], + scale_lines=False) + axins_test.set_xlim(1, 5) + axins_test.set_ylim(1, 5) + axins_test.annotate( + "Interesting", (3, 3), (0, 0), + textcoords="axes fraction", arrowprops=arrow_s + ) + ax_test.indicate_inset_zoom(axins_test, edgecolor="black") + + # Reference + ax_ref = fig_ref.gca() + ax_ref.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, + interpolation="nearest") + axins_ref = ax_ref.inset_axes([0.5, 0.5, 0.48, 0.48]) + axins_ref.set_xlim(1, 5) + axins_ref.set_ylim(1, 5) + axins_ref.imshow(im_data, origin="lower", cmap="Blues", alpha=0.5, + interpolation="nearest") + axins_ref.annotate( + "Interesting", (3, 3), (0, 0), + textcoords="axes fraction", arrowprops=arrow_s + ) + ax_ref.indicate_inset_zoom(axins_ref, edgecolor="black") + + +@check_figures_equal() +def test_3d_view(fig_test, fig_ref): + # The data... + X = Y = np.arange(-5, 5, 0.25) + X, Y = np.meshgrid(X, Y) + Z = np.sin(np.sqrt(X ** 2 + Y ** 2)) + + # Test Case... + ax1_test, ax2_test = fig_test.subplots( + 1, 2, subplot_kw=dict(projection="3d") + ) + ax1_test.plot_surface(X, Y, Z, cmap="plasma") + view(ax2_test, ax1_test) + ax2_test.view_init(elev=80) + ax2_test.set_xlim(-10, 10) + ax2_test.set_ylim(-10, 10) + ax2_test.set_zlim(-2, 2) + + # Reference + ax1_ref, ax2_ref = fig_ref.subplots( + 1, 2, subplot_kw=dict(projection="3d") + ) + ax1_ref.plot_surface(X, Y, Z, cmap="plasma") + ax2_ref.plot_surface(X, Y, Z, cmap="plasma") + ax2_ref.view_init(elev=80) + ax2_ref.set_xlim(-10, 10) + ax2_ref.set_ylim(-10, 10) + ax2_ref.set_zlim(-2, 2) + + +@check_figures_equal() +def test_polar_view(fig_test, fig_ref): + r = np.arange(0, 2, 0.01) + theta = 2 * np.pi * r + + # Test Case with polar coordinate system... + ax_t1, ax_t2 = fig_test.subplots(1, 2, subplot_kw=dict(projection="polar")) + ax_t1.plot(theta, r) + ax_t1.set_rmax(2) + view(ax_t2, ax_t1, scale_lines=False) + ax_t2.set_rmax(1) + + # Reference... + ax_r1, ax_r2 = fig_ref.subplots(1, 2, subplot_kw=dict(projection="polar")) + ax_r1.plot(theta, r) + ax_r1.set_rmax(2) + ax_r2.plot(theta, r) + ax_r2.set_rmax(1) + + +@check_figures_equal() +def test_map_projection_view(fig_test, fig_ref): + x = np.linspace(-2.5, 2.5, 20) + y = np.linspace(-1, 1, 20) + + def circ_gen(): + return plt.Circle((1.5, 0.25), 0.7, ec="black", fc="blue") + + # Test case... + ax_t1 = fig_test.add_subplot(1, 2, 1, projection="hammer") + ax_t2 = fig_test.add_subplot(1, 2, 2, projection="lambert") + ax_t1.grid(True) + ax_t2.grid(True) + ax_t1.plot(x, y) + ax_t1.add_patch(circ_gen()) + view(ax_t2, ax_t1) + + # Reference... + ax_r1 = fig_ref.add_subplot(1, 2, 1, projection="hammer") + ax_r2 = fig_ref.add_subplot(1, 2, 2, projection="lambert") + ax_r1.grid(True) + ax_r2.grid(True) + ax_r1.plot(x, y) + ax_r1.add_patch(circ_gen()) + ax_r2.plot(x, y) + ax_r2.add_patch(circ_gen()) + + +@check_figures_equal() +def test_double_view(fig_test, fig_ref): + # Test case... + ax_test1, ax_test2, ax_test3 = fig_test.subplots(1, 3) + + ax_test1.add_patch(plt.Circle((1, 1), 1.5, ec="black", fc=(0, 0, 1, 0.5))) + ax_test3.add_patch(plt.Circle((3, 1), 1.5, ec="black", fc=(1, 0, 0, 0.5))) + + ax_test2 = view( + view(ax_test2, ax_test1, scale_lines=False), + ax_test3, scale_lines=False + ) + + ax_test2.set_aspect(1) + ax_test2.set_xlim(-0.5, 4.5) + ax_test2.set_ylim(-0.5, 2.5) + + # Reference... + ax_ref1, ax_ref2, ax_ref3 = fig_ref.subplots(1, 3) + + ax_ref1.add_patch(plt.Circle((1, 1), 1.5, ec="black", fc=(0, 0, 1, 0.5))) + ax_ref3.add_patch(plt.Circle((3, 1), 1.5, ec="black", fc=(1, 0, 0, 0.5))) + + ax_ref2.add_patch(plt.Circle((1, 1), 1.5, ec="black", fc=(0, 0, 1, 0.5))) + ax_ref2.add_patch(plt.Circle((3, 1), 1.5, ec="black", fc=(1, 0, 0, 0.5))) + ax_ref2.set_aspect(1) + ax_ref2.set_xlim(-0.5, 4.5) + ax_ref2.set_ylim(-0.5, 2.5) + + for ax in (ax_test1, ax_test3, ax_ref1, ax_ref3): + ax.set_aspect(1) + ax.relim() + ax.autoscale_view() + + +@check_figures_equal() +def test_stop_viewing(fig_test, fig_ref): + np.random.seed(1) + data = np.random.randint(0, 10, 10) + + # Test case... Create a view and stop it... + ax1_test, ax2_test = fig_test.subplots(1, 2) + + ax1_test.plot(data) + ax1_test.text(0.5, 0.5, "Hello") + + view(ax2_test, ax1_test) + stop_viewing(ax2_test, ax1_test) + + # Reference, just don't plot anything at all in the second axes... + ax1_ref, ax2_ref = fig_ref.subplots(1, 2) + + ax1_ref.plot(data) + ax1_ref.text(0.5, 0.5, "Hello") + + +# On MacOS the results are off by an extremely tiny amount, can't even see in diff. It's close enough... +@check_figures_equal(tol=0.02 if sys.platform.startswith("darwin") else 0) +def test_log_line(fig_test, fig_ref): + data = [i for i in range(1, 10)] + + # Test case... Create a view and stop it... + ax1_test, ax2_test = fig_test.subplots(1, 2) + + ax1_test.set(xscale="log", yscale="log") + ax1_test.plot(data, "-o") + + view(ax2_test, ax1_test, scale_lines=False) + ax2_test.set_xlim(-1, 10) + ax2_test.set_ylim(-1, 10) + + # Reference, just don't plot anything at all in the second axes... + ax1_ref, ax2_ref = fig_ref.subplots(1, 2) + + ax1_ref.set(xscale="log", yscale="log") + ax1_ref.plot(data, "-o") + ax2_ref.plot(data, "-o") + ax2_ref.set_xlim(-1, 10) + ax2_ref.set_ylim(-1, 10) + + +@check_figures_equal() +def test_log_scatter(fig_test, fig_ref): + data = [i for i in range(1, 11)] + + # Test case... Create a view and stop it... + ax1_test, ax2_test = fig_test.subplots(1, 2) + + ax1_test.set(xscale="log", yscale="log") + ax1_test.scatter(data, data) + + view(ax2_test, ax1_test, scale_lines=False) + ax2_test.set_xlim(-5, 15) + ax2_test.set_ylim(-5, 15) + + # Reference, just don't plot anything at all in the second axes... + ax1_ref, ax2_ref = fig_ref.subplots(1, 2) + + ax1_ref.set(xscale="log", yscale="log") + ax1_ref.scatter(data, data) + ax2_ref.scatter(data, data) + ax2_ref.set_xlim(-5, 15) + ax2_ref.set_ylim(-5, 15) + + +@check_figures_equal() +def test_log_scatter_with_colors(fig_test, fig_ref): + data = [i for i in range(1, 11)] + colors = list("rgbrgbrgbr") + + # Test case... Create a view and stop it... + ax1_test, ax2_test = fig_test.subplots(1, 2) + + ax1_test.set(xscale="log", yscale="log") + ax1_test.scatter(data, data, color=colors) + + view(ax2_test, ax1_test, scale_lines=False) + ax2_test.set_xlim(-5, 15) + ax2_test.set_ylim(-5, 15) + + # Reference, just don't plot anything at all in the second axes... + ax1_ref, ax2_ref = fig_ref.subplots(1, 2) + + ax1_ref.set(xscale="log", yscale="log") + ax1_ref.scatter(data, data, color=colors) + ax2_ref.scatter(data, data, color=colors) + ax2_ref.set_xlim(-5, 15) + ax2_ref.set_ylim(-5, 15) diff --git a/matplotview/tests/utils.py b/matplotview/tests/utils.py new file mode 100644 index 0000000..8ca3f56 --- /dev/null +++ b/matplotview/tests/utils.py @@ -0,0 +1,36 @@ +import numpy as np +import matplotlib.pyplot as plt + + +def figure_to_image(figure): + figure.canvas.draw() + img = np.frombuffer(figure.canvas.buffer_rgba(), dtype=np.uint8) + return img.reshape(figure.canvas.get_width_height()[::-1] + (4,))[..., :3] + + +def matches_post_pickle(figure): + import pickle + img_expected = figure_to_image(figure) + + saved_fig = pickle.dumps(figure) + plt.close("all") + + figure = pickle.loads(saved_fig) + img_result = figure_to_image(figure) + + return np.all(img_expected == img_result) + + +def plotting_test(num_figs=1, *args, **kwargs): + def plotting_decorator(function): + def test_plotting(): + plt.close("all") + res = function( + *(plt.figure(*args, **kwargs) for __ in range(num_figs)) + ) + plt.close("all") + return res + + return test_plotting + + return plotting_decorator diff --git a/requirements.txt b/requirements.txt index 564ed46..e685972 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -matplotlib>=3.5.1 \ No newline at end of file +matplotlib>=3.5.1 +numpy \ No newline at end of file diff --git a/setup.py b/setup.py index 7932fa8..9e53965 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ import setuptools -VERSION = "0.0.3" +VERSION = "1.0.2" with open("README.md", "r", encoding="utf-8") as fh: long_description = fh.read() @@ -13,9 +13,9 @@ description="A library for creating lightweight views of matplotlib axes.", long_description=long_description, long_description_content_type="text/markdown", - url="https://github.com/isaacrobinson2000/matplotview", + url="https://github.com/matplotlib/matplotview", project_urls={ - "Bug Tracker": "https://github.com/isaacrobinson2000/matplotview/issues", + "Bug Tracker": "https://github.com/matplotlib/matplotview/issues", }, classifiers=[ 'Development Status :: 3 - Alpha', @@ -32,7 +32,8 @@ ], license="PSF", install_requires=[ - "matplotlib>=3.5.1" + "matplotlib>=3.5.1", + "numpy" ], packages=["matplotview"], python_requires=">=3.7",