1

In short, instead of the version on the left, I would like the version on the right. Is there any way to do this without having to draw the figure fist? You can access the artist before, but at that point, the text is not set.

enter image description here enter image description here

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.text import Text


image = np.random.uniform(10000000, 100000000, (100, 100))

fig, ax = plt.subplots()
image_artist = ax.imshow(image)
colorbar = fig.colorbar(image_artist)
colorbar.ax.ticklabel_format()

fig.show()

for artist in colorbar.ax.yaxis.get_children():
    if isinstance(artist, Text) and artist.get_text():
        exp = artist.get_text().split('e')[1].replace('+', '')
        colorbar.ax.set_ylabel(rf'Parameter [U${{\times}}10^{{{exp}}}$]')
        artist.set_visible(False)

fig.show()
mapf
  • 1,906
  • 1
  • 14
  • 40
  • 1
    You can access the offset text via [`get_offset_text()`](https://matplotlib.org/api/_as_gen/matplotlib.axis.Axis.get_offset_text.html) – JohanC Jun 18 '20 at 22:43
  • 1
    See also [Adjust exponent text after setting scientific limits on matplotlib axis](https://stackoverflow.com/questions/31517156/adjust-exponent-text-after-setting-scientific-limits-on-matplotlib-axis). There `plt.tight_layout()`is suggested to force filling in the offset_text. Thereafter, you get the desired artist as `offset_text = colorbar.ax.yaxis.get_offset_text()` – JohanC Jun 18 '20 at 23:15
  • Great, this is basically what I was looking for. Such a shame though that these things are so badly documented. How are you supposed to know that the extra text generated by `ticklabel_format` is passed to the `offset_text`? Anyways.. do you have any idea on how to do this if you use `constrained_layout=True`? – mapf Jun 19 '20 at 05:54
  • You could also just divide your data by 10^6 or 10^7 before plotting, and then not worry about the offset text but just add that information to your label. – Paul Brodersen Jun 19 '20 at 09:36
  • Thanks @PaulBrodersen, I thought about that as well, but unfortunately, I don't know the order of magnitude, and it is very hard to determine, since in the case of a `colorbar`, it is directly dependent on the `clim` of the image. – mapf Jun 19 '20 at 10:47

2 Answers2

1

You cannot get any of the tick values until you trigger a draw, because ticks are evaluated lazily. So if you need information from the locators and formatters, you must call fig.canvas.draw(). Everything above about tight_layout is a red herring because it all calls fig.canvas.draw().

As for your actual request, this still calls fig.canvas.draw but that's just for the convenience of getting the exponent the formatter uses. You could easily get that yourself from the vlim values. Otherwise, this just sets the offset text to be blank rather than making a scientific notation label.

import numpy as np
import matplotlib
matplotlib.use('qt5agg')
import matplotlib.pyplot as plt
from matplotlib.text import Text
import matplotlib.ticker as mticker

class NoOffsetFormatter(mticker.ScalarFormatter):
    def get_offset(self):
        return ''

formatter = NoOffsetFormatter()

image = np.random.uniform(10000000, 100000000, (100, 100))

fig, ax = plt.subplots()
image_artist = ax.imshow(image)
colorbar = fig.colorbar(image_artist)
colorbar.ax.yaxis.set_major_formatter(formatter)
fig.canvas.draw()
exp = formatter.orderOfMagnitude
colorbar.ax.set_ylabel(rf'Parameter [U${{\times}}10^{{{exp}}}$]')
plt.show()
Jody Klymak
  • 4,979
  • 2
  • 15
  • 31
  • Thanks, what do you mean by "You could easily get that yourself from the vlim values."? I mean, in the end, that's what I want. So you are saying there is a way to get the order of magnitiude without having to draw the figure? Drawing the figure can be quite expensive, and I want to aviod it as much as possible, additionally so because I am using a canvas embedded in a PyQt UI, and I don't to 'update' the canvas at a point where it is not ready to be seen yet. – mapf Jun 26 '20 at 12:15
  • Sure you could just call the locator manually or copy the code that figures out the order of magnitude. – Jody Klymak Jun 26 '20 at 14:59
  • I'm sorry, but what is the locator? I've encountered this term quite a few times now, but I have never figured out what it is. – mapf Jun 26 '20 at 16:58
  • 1
    https://matplotlib.org/3.1.1/gallery/ticks_and_spines/tick-locators.html – Jody Klymak Jun 26 '20 at 17:25
  • Thanks! I know this is getting off-topic, but those locators are different from [these guys](https://matplotlib.org/3.2.1/api/_as_gen/matplotlib.axes.Axes.set_axes_locator.html) yes? – mapf Jun 26 '20 at 19:14
0

Ok, so I followed the clue pointed out by @JohanC in the comments that you could use fig.tight_layout() in order to 'trick' the figure into setting the text of the offset_text artist without having to draw the figure. The offset_text artist is being used by the ax.ticklabel_format() method to display the order of magnitude (again, as pointed out by @JohanC in the comments). This trick is explained in this post which is similar to mine, and seems to be a fair enough solution for most cases. However what if you don't want to use tight_layout, or even worse, you are using the incompatible constrained_layout instead (such as myself)?

Summary:

So I did a lot of digging through the matplotlib source code following the trace of tight_layout, and fortunately, I was successful. In short, a universal solution to this problem is to call ax.get_tightbbox(renderer), where renderer is the renderer of the figure. It should also be less expensive. The following MWE shows that this works even when using constrained_layout:

import sys
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.tight_layout import get_renderer
from matplotlib.backends.backend_qt5agg import \
    FigureCanvasQTAgg as FigureCanvas
# from matplotlib.transforms import Bbox
# from mpl_toolkits.axes_grid1 import make_axes_locatable

from PyQt5.QtWidgets import QDialog, QApplication, QGridLayout


class MainWindow(QDialog):
    def __init__(self):
        super().__init__()
        fig, ax = plt.subplots(constrained_layout=True)
        canvas = FigureCanvas(fig)
        lay = QGridLayout(self)
        lay.addWidget(canvas)
        self.setLayout(lay)

        image = np.random.uniform(10000000, 100000000, (100, 100))
        image_artist = ax.imshow(image)
        colorbar = fig.colorbar(image_artist)
        colorbar.ax.ticklabel_format()
        renderer = get_renderer(fig)
        colorbar.ax.get_tightbbox(renderer)
        colorbar.ax.yaxis.offsetText.set_visible(False)
        offset_text = colorbar.ax.yaxis.get_offset_text()
        exp = offset_text.get_text().split('e')[1].replace('+', '')
        colorbar.ax.set_ylabel(rf'Parameter [U${{\times}}10^{{{exp}}}$]')

        canvas.draw_idle()


if __name__ == '__main__':
    app = QApplication(sys.argv)
    GUI = MainWindow()
    GUI.show()
    sys.exit(app.exec_())

Step by step explanation:

Here is what I did:

  • I looked at the tight_layout source code. Via elimination, I realized that the important bit for this trick to work was the following statement,

    kwargs = get_tight_layout_figure(
          self, self.axes, subplotspec_list, renderer,
          pad=pad, h_pad=h_pad, w_pad=w_pad, rect=rect)
    

    which is great, because I also realized the the statement that 'essentially' makes tight_layout incompatible with constrained_layout is the call to subplots_adjust(**kwargs).

  • Then I looked at the get_tight_layout_figure source code. In order for this to have any effect on a colorbar, you need to use a workaround, since by default, the colorbar is added via a basic Axes instance, and not via an AxesSubplot instance. This is an important difference, because get_tight_layout_figure requires the subplotspec_list, which in turn is generated by get_subplotspec_list. The latter returns None in case of the colorbar.ax though, because, while an AxesSubplot instance comes with a locator, a regular Axes instance does not. The locator is what is being used in get_subplotspec_list to return the subplotspec. The workaround is then to use the approach described at the bottom here, by making the colorbar axes locatable:
    from mpl_toolkits.axes_grid1 import make_axes_locatable
    
    arr = np.arange(100).reshape((10, 10))
    fig = plt.figure(figsize=(4, 4))
    im = plt.imshow(arr, interpolation="none")
    
    divider = make_axes_locatable(plt.gca())
    cax = divider.append_axes("right", "5%", pad="3%")
    plt.colorbar(im, cax=cax)
    
    plt.tight_layout()
    
  • With this, I was able to run the get_tight_layout_figure on my colorbar.ax:
    from matplotlib.tight_layout import get_renderer, get_tight_layout_figure
    renderer = get_renderer(fig)
    gridspec = colorbar.ax.get_axes_locator().get_subplotspec()
    get_tight_layout_figure(fig, [colorbar.ax], [gridspec], renderer)
    
  • Again via elimination, I realized that the important statement in get_tight_layout_figure for the trick to work, was this statement:

    kwargs = auto_adjust_subplotpars(fig, renderer,
                                   nrows_ncols=(max_nrows, max_ncols),
                                   num1num2_list=num1num2_list,
                                   subplot_list=subplot_list,
                                   ax_bbox_list=ax_bbox_list,
                                   pad=pad, h_pad=h_pad, w_pad=w_pad)
    

    This made things a lot easier again, because for this function, you only need the fig and renderer, as well as nrows_ncols, num1num2_list and subplot_list. The latter three arguments are luckyly easy enough to obtain / simulate, where nrows_ncols and num1num2_list are lists of numbers, in this simple case (1, 1) and [(0, 0)] respectively, and subplot_list only contains the colorbar.ax. What's more, the workaround introduced above doesn't really work with constrained_laout, since part of the colorbar axes (in particular the label which all of this is about) can be cut off:

    enter image description here

  • So then, you guessed it, I looked into the auto_adjust_subplotpars source code. And again, via elimination, I this time quickly found the relevant line of code:

    tight_bbox_raw = union([ax.get_tightbbox(renderer) for ax in subplots
                            if ax.get_visible()])
    

    The important part here is of course ax.get_tightbbox(renderer), as you can tell by my solution. This is as far as I could trace it, though I do believe that it should be possible to go even a little bit further. It was actually not that easy to find the relevant sour code for the get_tightbbox-method, because even though the code suggests that what is being called is Axes.get_tightbbox, which at least can also be found in the docs (although there is no link to the source code), what is actually being used is the Artist.get_tightbbox, of which, for some reason, there is no documentation, however it does exist in the source code. I extracted it and made my own 'detached' version, to see if I could go even deeper:

    from matplotlib.transforms import Bbox
    
    def get_tightbbox(artist, renderer):
        """
        Like `Artist.get_window_extent`, but includes any clipping.
    
        Parameters
        ----------
        renderer : `.RendererBase` instance
            renderer that will be used to draw the figures (i.e.
            ``fig.canvas.get_renderer()``)
    
        Returns
        -------
        bbox : `.BBox`
            The enclosing bounding box (in figure pixel co-ordinates).
        """
        bbox = artist.get_window_extent(renderer)
        if artist.get_clip_on():
            clip_box = artist.get_clip_box()
            if clip_box is not None:
                bbox = Bbox.intersection(bbox, clip_box)
            clip_path = artist.get_clip_path()
            if clip_path is not None and bbox is not None:
                clip_path = clip_path.get_fully_transformed_path()
                bbox = Bbox.intersection(bbox, clip_path.get_extents())
    
        return bbox
    

    But here, something very curious happened, which I cannot explain, and ultimately stopped me from further investigation:

running get_tightbbox(colorbar.ax, renderer) is not the same as running colorbar.ax.get_tightbbox(renderer)!

I have no idea why. Running get_tightbbox(colorbar.ax, renderer), get_tightbbox is only executed once (as you would assume), but running colorbar.ax.get_tightbbox(renderer), it runs several times, for a bunch, but not all, of the colorbar.ax's children. I tried to emulate it but looping over the children and run get_tightbbox individually for each (in particular I tested this on the offset_text artist of course), but it didn't have the same effect. It does not work. So for now, colorbar.ax.get_tightbbox(renderer) is the way to go.

mapf
  • 1,906
  • 1
  • 14
  • 40
  • 1
    Using tight layout *does* draw the figure using fig.canvas.draw. That’s how it knows how large everything will be. You may as well call that yourself. That said, you could pretty easily get what you want by writing a new formatter that just doesn’t pass the exponent to the offset text. – Jody Klymak Jun 21 '20 at 15:27
  • Oh no, all this work for nothing :( but thanks for letting me know! – mapf Jun 26 '20 at 12:09