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", pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy