Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New Feature: Add sub-figure plotting #3343

Open
wants to merge 13 commits into
base: RELEASE_next_minor
Choose a base branch
from

Conversation

CSSFrancis
Copy link
Member

@CSSFrancis CSSFrancis commented Mar 26, 2024

Description of the change

This is still a fairly new matplotlib feature and still Provisional but I think there is/ will be a fair bit of ongoing support for it as many people are interested in this.

Additionally picking and dragging the marker isn't supported until matplotlib 3.9 (matplotlib/matplotlib#27343) and the draw_without_rendering function isn't supported which causes a small bug related to the color bar. Replacing with draw_idle works fine though.

I think this is ultimately (going to be) a better choice than the previous horizontal plotting option but there are still some bugs related to it.

A few sentences and/or a bulleted list to describe and motivate the change:

  • Add ability to pass fig when plotting to specify a figure output

Progress of the PR

  • Change implemented (can be split into several points),
  • Add/update examples for plotting,
  • Add option to preferences (with "experimental label") to use subfigure to opt in using it default,
  • Document that using subfigure is slower than separate figure,
  • add an changelog entry in the upcoming_changes folder (see upcoming_changes/README.rst),
  • Check formatting changelog entry in the readthedocs doc build of this PR (link in github checks)
  • add tests,
  • ready for review.

Minimal example of the bug fix or the new feature

%matplotlib ipympl
import matplotlib.pyplot as plt

import hyperspy.api as hs
import numpy as np
rng = np.random.default_rng()
s = hs.signals.Signal2D(rng.random((10,10,10,10)))
fig = plt.figure(figsize=(10,5))
subfigs = fig.subfigures(1, 2, wspace=0.07)
s.plot(navigator_kwds=dict(fig=subfigs[0]), fig=subfigs[1])

@CSSFrancis
Copy link
Member Author

CSSFrancis commented Mar 26, 2024

Some other things:

We can have multiple signals. Of course both of them respond to the key press events however that is kind of nice I guess in some cases. It might be nice to give the option to pair the two or keep them seperate.

%matplotlib ipympl
import matplotlib.pyplot as plt

import hyperspy.api as hs
import numpy as np
rng = np.random.default_rng()
s = hs.signals.Signal2D(rng.random((10,10,10,10)))
s2 = hs.signals.Signal2D(rng.random((11,11,10,10)))

fig = plt.figure(figsize=(8,7))
subfigs = fig.subfigures(2, 2, wspace=0.07)
s.plot(navigator_kwds=dict(fig=subfigs[0,0]), fig=subfigs[0,1])
s2.plot(navigator_kwds=dict(fig=subfigs[1,0]), fig=subfigs[1,1])
Screen.Recording.2024-03-26.at.6.29.56.PM.mov

Copy link

codecov bot commented Mar 26, 2024

Codecov Report

Attention: Patch coverage is 85.41667% with 7 lines in your changes are missing coverage. Please review.

Project coverage is 80.53%. Comparing base (18caceb) to head (93b0c2e).

Files Patch % Lines
hyperspy/drawing/figure.py 66.66% 4 Missing and 1 partial ⚠️
hyperspy/drawing/image.py 71.42% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@                  Coverage Diff                   @@
##           RELEASE_next_minor    #3343      +/-   ##
======================================================
- Coverage               80.54%   80.53%   -0.01%     
======================================================
  Files                     147      147              
  Lines                   21868    21880      +12     
  Branches                 5147     5150       +3     
======================================================
+ Hits                    17613    17621       +8     
- Misses                   3037     3042       +5     
+ Partials                 1218     1217       -1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@CSSFrancis
Copy link
Member Author

CSSFrancis commented Mar 27, 2024

Some more examples:

This is making inset images from ROI's. It might be nice to make it easier to do things like change the color of the edge for some plot or return the signal axis. Note that if you had something like a time series I think that you could link these together with something like the plot_signals function.

fig = plt.figure(figsize=(5,3))
gs = fig.add_gridspec(6,10)
sub1 = fig.add_subfigure(gs[0:6,0:6])

sub2 = fig.add_subfigure(gs[0:2,6:8])
sub3 = fig.add_subfigure(gs[2:4,7:9])
sub4 = fig.add_subfigure(gs[4:6,6:8])

s2 = hs.signals.Signal2D(rng.random((10,10, 30,30)))
r1 = hs.roi.RectangularROI(1,1,3,3)
r2= hs.roi.RectangularROI(4,4,6,6)
r3= hs.roi.RectangularROI(3,7,5,9)


s3 = r1(s2).sum()
s4 = r2(s2).sum()
s5 = r3(s2).sum()

navigator = s2.sum(axis=(2,3)).T

navigator.plot(fig=sub1, colorbar=False, axes_off=True, title="")
s3.plot(fig=sub2, colorbar=False, axes_off=True, title="")
s4.plot(fig=sub3, colorbar=False, axes_off=True, title="")
s5.plot(fig=sub4, colorbar=False, axes_off=True, title="")

red_edge= hs.plot.markers.Squares(offset_transform="axes",
                            offsets=(0.5,0.5),
                            units="width",
                            widths=1,
                            color="r",
                            linewidth=5,
                            facecolor="none")
s3.add_marker(red_edge)

green_edge = hs.plot.markers.Squares(offset_transform="axes",
                            offsets=(0.5,0.5),
                            units="width",
                            widths=1,
                            color="g",
                            linewidth=5,
                            facecolor="none")
s4.add_marker(green_edge)

yellow_edge = hs.plot.markers.Squares(offset_transform="axes",
                            offsets=(0.5,0.5),
                            units="width",
                            widths=1,
                            color="y",
                            linewidth=5,
                            facecolor="none")
s5.add_marker(yellow_edge)

r1.add_widget(navigator, color="r")
r2.add_widget(navigator, color="g")
r3.add_widget(navigator, color="y")
image

@CSSFrancis
Copy link
Member Author

CSSFrancis commented Mar 27, 2024

And another example that is a bit more interactive:

fig = plt.figure(figsize=(5,3))
gs = fig.add_gridspec(6,15)
sub0 = fig.add_subfigure(gs[1:5,0:5])

sub1 = fig.add_subfigure(gs[0:6,5:11])

sub2 = fig.add_subfigure(gs[0:2,11:13])
sub3 = fig.add_subfigure(gs[2:4,12:14])
sub4 = fig.add_subfigure(gs[4:6,11:13])

s2 = hs.signals.Signal2D(rng.random((20, 10,10, 30,30)))
r1 = hs.roi.RectangularROI(1,1,3,3)
r2= hs.roi.RectangularROI(4,4,6,6)
r3= hs.roi.RectangularROI(3,7,5,9)


s3 = r1(s2,axes=(0,1)).sum(axis=(0,1))
s4 = r2(s2,axes=(0,1)).sum(axis=(0,1))
s5 = r3(s2,axes=(0,1)).sum(axis=(0,1))

navigator = s2.sum(axis=(3,4)).transpose(2)

navigator.plot(navigator_kwds=dict(fig=sub0),fig=sub1, colorbar=False, axes_off=True, title="")
s3.plot(fig=sub2, colorbar=False, axes_off=True, title="", axes_manager=navigator.axes_manager, navigator=None)
s4.plot(fig=sub3, colorbar=False, axes_off=True, title="", axes_manager=navigator.axes_manager, navigator=None)
s5.plot(fig=sub4, colorbar=False, axes_off=True, title="", axes_manager=navigator.axes_manager, navigator=None)

red_edge= hs.plot.markers.Squares(offset_transform="axes",
                            offsets=(0.5,0.5),
                            units="width",
                            widths=1,
                            color="r",
                            linewidth=5,
                            facecolor="none")
s3.add_marker(red_edge)

green_edge = hs.plot.markers.Squares(offset_transform="axes",
                            offsets=(0.5,0.5),
                            units="width",
                            widths=1,
                            color="g",
                            linewidth=5,
                            facecolor="none")
s4.add_marker(green_edge)

yellow_edge = hs.plot.markers.Squares(offset_transform="axes",
                            offsets=(0.5,0.5),
                            units="width",
                            widths=1,
                            color="y",
                            linewidth=5,
                            facecolor="none")
s5.add_marker(yellow_edge)

r1.add_widget(navigator, color="r")
r2.add_widget(navigator, color="g")
r3.add_widget(navigator, color="y")
image

@CSSFrancis
Copy link
Member Author

@ericpre @magnunor Any thoughts on this? I think this should be fairly useful.

@CSSFrancis CSSFrancis mentioned this pull request Apr 1, 2024
10 tasks
@CSSFrancis CSSFrancis requested a review from ericpre April 6, 2024 16:41
Copy link
Member

@ericpre ericpre left a comment

Choose a reason for hiding this comment

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

I wasn't aware of matplotlib.figure.SubFigure and it seems that this is exactly what we need! This will be very convenient!

  • This PR introduce an issue: s._plot.signal_plot.figure can now be a matplotlib.figure.SubFigure instead of matplotlib.figure.Figure this breaks closing figure interactively or programmatically: we need access to the matplotlib.figure.Figure object:
    • fix call to MPL_HyperExplorer.close when using Subfigure
    • fix on close event figure connection when using Subfigure
  • If there are now issue with events/marker and only matplotlib 3.9 works fine, should we prevent the use of subfigure with matplotlib < 3.9?
  • Should we had an option to the preference to enable plotting using subfigure by default (as an experimental feature)?
    • good default of aspect ratio of figure will most likely be needed for subfigure
    • may be worth adding some plt.tight_layout to remove some of the empty space around the subfigure?

Comment on lines 36 to 39
navigator.plot(fig=sub1, colorbar=False, axes_off=True, title="", plot_indices=False)
s2.plot(fig=sub2, colorbar=False, axes_off=True, title="", plot_indices=False)
s3.plot(fig=sub3, colorbar=False, axes_off=True, title="", plot_indices=False)
s4.plot(fig=sub4, colorbar=False, axes_off=True, title="", plot_indices=False)
Copy link
Member

Choose a reason for hiding this comment

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

Would it make sense to make these signals interactive when moving the ROI?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yea it would make sense. That requires matplotlib==3.9 otherwise you can't move the ROI's :)

@@ -571,6 +573,8 @@ def format_coord(x, y):
# `draw_all` is deprecated in matplotlib 3.6.0
if Version(matplotlib.__version__) <= Version("3.6.0"):
self._colorbar.draw_all()
elif isinstance(self.figure, SubFigure):
self.figure.canvas.draw_idle() # draw without rendering not supported for sub-figures
Copy link
Member

Choose a reason for hiding this comment

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

Add a test for this block?

Creating Custom Layouts
=======================

Custom layouts for hyperspy figures can be created using the `matplotlib.figure.SubFigure` class. Passing
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
Custom layouts for hyperspy figures can be created using the `matplotlib.figure.SubFigure` class. Passing
Custom layouts for hyperspy figures can be created using the :class:`matplotlib.figure.SubFigure` class. Passing

==========

ROI's can be powerful tools to help visualize data. In this case we will define ROI's in hyperspy, sum
the data within the ROI, and then plot the sum as a signal. Using the `matplotlib.figure.SubFigure` class
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
the data within the ROI, and then plot the sum as a signal. Using the `matplotlib.figure.SubFigure` class
the data within the ROI, and then plot the sum as a signal. Using the :class:`matplotlib.figure.SubFigure` class

Comment on lines +17 to +23
gs = fig.add_gridspec(6, 10)
sub1 = fig.add_subfigure(gs[0:6, 0:6])
sub2 = fig.add_subfigure(gs[0:2, 6:8])
sub3 = fig.add_subfigure(gs[2:4, 7:9])
sub4 = fig.add_subfigure(gs[4:6, 6:8])
Copy link
Member

Choose a reason for hiding this comment

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

Simplify the grid to make easier to understand (and also when looking at the image, the reason for this layout is not obvious)?

Copy link
Member Author

Choose a reason for hiding this comment

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

@ericpre that is a good point. I might have to play around with this one slightly and make some fake data. I use something like this to show average diffraction patterns from ROI's in real space.

Here's a gif which is similar to the second example.

ZrCuTiAl-Crystalization

Comment on lines +1 to +2
Making Custom Layouts for Plots
===============================
Copy link
Member

Choose a reason for hiding this comment

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

Wondering if this is not too specific?

Suggested change
Making Custom Layouts for Plots
===============================
Data visualization
==================

To make the corresponding heading in the user guide?

@ericpre ericpre added this to the v2.1 milestone Apr 7, 2024
@CSSFrancis
Copy link
Member Author

I wasn't aware of matplotlib.figure.SubFigure and it seems that this is exactly what we need! This will be very convenient!

  • This PR introduce an issue: s._plot.signal_plot.figure can now be a matplotlib.figure.SubFigure instead of matplotlib.figure.Figure this breaks closing figure interactively or programmatically: we need access to the matplotlib.figure.Figure object:

    • fix call to MPL_HyperExplorer.close when using Subfigure
    • fix on close event figure connection when using Subfigure

@ericpre I just realized this the other day using this. Apparently, I never tried to replot anything.

  • If there are now issue with events/marker and only matplotlib 3.9 works fine, should we prevent the use of subfigure with matplotlib < 3.9?

This issue is that you can't drag the navigator. Markers still work but it also causes issues with interactive ROIs. I still think this is useful for figure composition without that interactivity but it would be good to throw a warning that if you want to use subfigures you are going to be happier with matplotlib 3.9.

  • Should we had an option to the preference to enable plotting using subfigure by default (as an experimental feature)?

I think that would be great. I'm sure there are a lot of people interested in this feature as it's one of the more commonly requested ones.

  • good default of aspect ratio of figure will most likely be needed for subfigure

Yea that was something we could play around with.

  • may be worth adding some plt.tight_layout to remove some of the empty space around the subfigure?

It might be a good default behavior to have tight_layout=True

@CSSFrancis
Copy link
Member Author

@ericpre the closing is a bit more complicated and I wonder what the best way to do that is?

We can create a new class which allows you to wrap the matplotlib.figure.Figure class. Then you can add subfigures/ connect events/ remove events on close. That allows us to add any number of hyperspy.drawing.figures.BlittedFigure objects to the plot which allows things like scaling to multiple navigators.

@ericpre
Copy link
Member

ericpre commented Apr 8, 2024

I suspect that they may be two options:

  • Subclass matplotlib.figure.Figure add the hyperspy close event functionality and refactor to integrate with BlittedFigure
  • make a API to provide the handle to the matplotlib.figure.Figure object along side matplotlib.figure.SubFigure in BlittedFigure. When subfigure are used, we just need to be able to access both objects.

I would go for the second one because it should be simple. The first one will need a refactor which (as far as I understand) will bring any significant benefit. Do you want me to make a PR to this branch to try to fix the close event business?

One thing that I didn't check (and don't know off the top of my head): how does using subfigures impact the plotting performance? With backend supporting blitting, most likely not that much, because only the relevant part of the figure redrawn but for other backend, the size/number of elements in the figure to redraw may end up being typically 2x larger?

@CSSFrancis
Copy link
Member Author

I suspect that they may be two options:

  • Subclass matplotlib.figure.Figure add the hyperspy close event functionality and refactor to integrate with BlittedFigure
  • make a API to provide the handle to the matplotlib.figure.Figure object along side matplotlib.figure.SubFigure in BlittedFigure. When subfigure are used, we just need to be able to access both objects.

I would go for the second one because it should be simple. The first one will need a refactor which (as far as I understand) will bring any significant benefit. Do you want me to make a PR to this branch to try to fix the close event business?

You can give it a shot if you want. I'm not sure that I completely follow the close() logic. There are complicated cases such as plotting two separate signals on the same figure which might be difficult to handle.

@ericpre
Copy link
Member

ericpre commented Apr 8, 2024

The current logic on closing figure is:

  • remove markers
  • disconnect events
  • trigger a close event
  • close the matplotlib figure
  • reset attributes to default values

The issue with subfigure is that the logic will run for all of them and it is not obvious how to close the matplotlib figure. As you said, maybe we need a separate Figure class to handle the "main" closing loop separately from the SubFigure! 😄

@ericpre
Copy link
Member

ericpre commented Apr 10, 2024

@CSSFrancis, the close event should be sorted now.

Instead of tight_layout, we should use constrained layout, because tight_layout has shortcomings: colorbar is ignored and label get cropped or overlap when changing the figure size, etc.)

Adapting one of your example:

import hyperspy.api as hs
import matplotlib.pyplot as plt
import numpy as np

rng = np.random.default_rng()
s = hs.signals.Signal2D(rng.random((100, 100, 100)))


fig = plt.figure(figsize=(15, 7), layout="constrained")
subfigs = fig.subfigures(1, 2)
s.plot(
    navigator_kwds=dict(fig=subfigs[0]),
    fig=subfigs[1],
    )

Matplotlib 3.9rc2 has been released yesterday, it would be good to make the example interactive as discussed above with a warning if matplotlib <3.9 is installed.

@CSSFrancis
Copy link
Member Author

CSSFrancis commented Apr 10, 2024

Matplotlib 3.9rc2 has been released yesterday, it would be good to make the example interactive as discussed above with a warning if matplotlib <3.9 is installed.

@ericpre Thank you for doing that! I can look into the interactive example later tonight and the warning with matplotlib 3.9 unless you want to do that.

@ericpre
Copy link
Member

ericpre commented May 6, 2024

See matplotlib/matplotlib#28177 for getting the matplotlib figure or subfigure.

@ericpre ericpre modified the milestones: v2.1, v2.2 May 8, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants