Skip to content

Revisiting figure size API #158

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

Open
daskol opened this issue Feb 5, 2025 · 8 comments
Open

Revisiting figure size API #158

daskol opened this issue Feb 5, 2025 · 8 comments

Comments

@daskol
Copy link

daskol commented Feb 5, 2025

Rationale

Currently, tueplots provides incomplete and inconsistent API for figure size specification. The issue is that tueplots assumes that user always request the size of entire figure with all subfigures but it is highly inconvenient in case of rendering subfigures separately and manual collation on page later.

Here is a specific example. ICLR-styled document has a single column of width 5.5. One needs two plots side-by-sides presented as a single one figure. Thus, manual manipulation of figure dimensions is required.

import matplotlib as mpl
import matplotlib.pyplot as plt
from tueplots.bundles import iclr2024

with mpl.rc_context(iclr2024()):
    width, height = mpl.rcParams['figsize.figsize']
    width = width / 2  # Consider gap between figures!

    # Render the left subfigure.
    fig, ax = plt.subplots(nrows=1, ncols=1, figsize=(width, height))
    fig.savefig('fig/subplot1.png')

    # Then render the right subfigure.
    fig, ax = plt.subplots(nrows=1, ncols=1, figsize=(width, height))
    fig.savefig('fig/subplot2.png')

Proposal

I suggest to explicit specification of page grid and figure size in grid coordinates. This is similar to how grid system works in HTML/CSS. It is simple, clear, and flexible but slightly more verbose. However, it enables precise figure size specification for an arbitrary subfigure composition on a page. Optionally, figsize() routine can obtain arguments for width and height of an entire figure to avoid automatic guessing.

from typing import TypeAlias

Gap: TypeAlias = tuple[float, float] | float

GOLDEN_RATIO = 1.6180339887


def figsize(size=(1, 1), grid=(1, 1), gap: Gap | None = None,
            width: float = 5.5, height: float | None = None,
            ) -> tuple[float, float]:
    """Calculate figure size of `size` in units of `grid` with optional `gap`
    between rows and columns.

    Args:
      size: Size of figure in terms of span size over `grid`.
      grid: Coordinate grid inscribed in page of width `width`.
      gap: Optional gap between rows and/or columns.
      width: Total width of page.
      height: Total height of page if specified; otherwise, no height limit.

    Returns:
      A tuple of figure height and width.
    """
    # TODO(@daskol): Use `height` for size calculation.
    match gap:
        case None:
            gap = (0, 0)  # More complex in practice.
        case float():
            gap = (gap, gap)
    page_width = float(width)  # For example, 5.5in (ICLR 2024).
    free_width = page_width - (grid[1] - 1) * gap[1]
    col_width = free_width / grid[1]
    row_height = col_width / GOLDEN_RATIO
    shape = (row_height, col_width)
    return tuple([(x * y + (x - 1) * z) for x, y, z in zip(size, shape, gap)])
Example and tests
def test_figsize():
    # one column(s), one row(s), no gap.
    actual = figsize(size=(1, 1), grid=(1, 1))
    assert actual == (3.3991869382292417, 5.5)

    # two column(s), one row(s), no gap.
    actual = figsize(size=(1, 1), grid=(1, 2))
    assert actual == (1.6995934691146208, 2.75)

    # two column(s), one row(s), col/row gap.
    actual = figsize(size=(1, 1), grid=(1, 2), gap=0.1)
    assert actual == (1.6686917696761732, 2.7)

    # two column(s), one row(s), column gap.
    actual = figsize(size=(1, 1), grid=(1, 2), gap=(0.1, 0.0))
    assert actual == (1.6995934691146208, 2.75)

    # two column(s), two rows(s), no gap.
    actual = figsize(size=(1, 1), grid=(2, 2))
    assert actual == (1.6995934691146208, 2.75)
    desired = figsize(size=(1, 1), grid=(1, 2))
    assert actual == desired

What are your thoughts on this matter?

@pnkraemer
Copy link
Owner

Hi - thanks for reaching out!

Tueplots' figure sizes support a rel_width argument. Could setting rel_width=0.5 in eg figsizes.iclr2024 or bundles.iclr2024 be what you're looking for?

@daskol
Copy link
Author

daskol commented Feb 10, 2025

It closes some use cases. Thank you!

However, it does not cover padding issues. Specifically, it is still unclear how one can make the following layout without manual adjustments.

AAAA BBBB CCCC
AAAA BBBB CCCC
AAAA DDDD EEEE
AAAA DDDD EEEE
FFFFFFFFF GGGG
FFFFFFFFF GGGG

@pnkraemer
Copy link
Owner

Thanks for following up. Yes, padding is a bit tedious in this context. One relatively simple fix that might work is to adjust rel_width up or down according to the desired padding.

How critical is it to make all subplots in separate scripts? I create plots with layouts like the one you're providing relatively frequently. I typically combine a global figure size (via tueplots) with plt.subplot_mosaic and found this to be quite easy to use. What do you think?

@daskol
Copy link
Author

daskol commented Feb 10, 2025

How critical is it to make all subplots in separate scripts?

Honestly, it is quite rare case (one figure per 10 pages paper). The need to split figure rendering part by part stems from the use of addition (vector) graphics that gives instructive image or illustrates entire experiment setup. In this case, it much simpler to collate figure manually.

Personally, I use a simple routine that adjust figure size in rcParams directly. It is a bit annoying to copy it from one repo to another. And I reported the issue since it could be interesting for general audience.

@pnkraemer
Copy link
Owner

The need to split figure rendering part by part stems from the use of addition (vector) graphics that gives instructive image or illustrates entire experiment setup.

Oh, I haven't thought about that. This use case seems worth thinking about, indeed. Thanks for raising this :)

I looked some more into it. Bad news: I struggled a bit with understanding your code example, but I don't know much HTML/CSS, so that's likely the reason. Good news: One can load SVGs into matplotlib figures and use Tueplots to configure the size of that figure. I drafted a tutorial on how to make this work. Could you maybe have a look at this draft to see whether it solves your problem? Thanks!

@pnkraemer
Copy link
Owner

Sorry to bother you again, @daskol... Does the thumbs-up reaction to the previous message mean you like the solution, or does it mean something else? 😅

Thanks for your feedback; it's much appreciated 😊

@daskol
Copy link
Author

daskol commented Feb 16, 2025

Sorry for the delay.

Sorry to bother you again, @daskol... Does the thumbs-up reaction to the previous message mean you like the solution, or does it mean something else? 😅

No problem. It was just a sign that I have read your reply but haven't figured out the workaround yet. Sorry. 😅

I drafted a tutorial on how to make this work. Could you maybe have a look at this draft to see whether it solves your problem?

Embedding any graphics and in particular SVG graphics as rasterized PNG is indeed a workaround. However, benefits of vector images (selectable text, infinite zoom in, small memory footprint) are lost in this case. Specifically, Matplotlib rasterizes a raster image once again in order to fit a bounding box of a target area. Another issue is that rendering figures with rasterized SVG graphics to vector graphics produces a vector image with the rasterized original vector graphics (i.e. everything can be vector but collation with Matplotlib inevitably turns the original vector graphics into raster one). Thus I would prefer manual collation to collation with Matplotlib.

I struggled a bit with understanding your code example, but I don't know much HTML/CSS, so that's likely the reason.

I fixed the code in the initial comment and prepared a usage example.

Usage example
import matplotlib.pyplot as plt


def annotate(ax: plt.Axes, text: str):
    ax.annotate(text, (0.5, 0.5), transform=ax.transAxes,
                ha='center', va='center', fontsize=32, color='darkgrey')


# Vector graphics spans two rows (A).
layout = [['A', 'B'],
          ['A', 'C']]

# Render all subfigures as a single combined figure.
fig_size = figsize(size=(1, 1), grid=(1, 1))  # Use defaults.
fig_size = fig_size[::-1]  # TODO: Swap width and height.
assert fig_size == (5.5, 3.3991869382292417)

fig, axs = plt.subplot_mosaic(layout, figsize=fig_size, layout='constrained')
annotate(axs['A'], 'A')
annotate(axs['B'], 'B')
annotate(axs['C'], 'C')
fig.savefig('full.png', dpi=144)
plt.close(fig)

# Each plot (B and C) span a single column and single row.
fig_size = figsize(size=(1, 1), grid=(2, 2))
fig_size = fig_size[::-1]  # TODO: Swap width and height.
assert fig_size == (2.75, 1.6995934691146208)

for ch in 'BC':
    fig, ax = plt.subplots(figsize=fig_size, layout='constrained')
    annotate(ax, ch)
    fig.savefig(f'{ch}.png', dpi=144)
    plt.close(fig)

If one verifies the real sizes of the resulting images, then it turns out that the size of full.png is double of B.png or C.png, meaning everything is correct!

$ file *.png
B.png:    PNG image data, 396 x 244, 8-bit/color RGBA, non-interlaced
C.png:    PNG image data, 396 x 244, 8-bit/color RGBA, non-interlaced
full.png: PNG image data, 792 x 489, 8-bit/color RGBA, non-interlaced

@pnkraemer
Copy link
Owner

Thanks for elaborating! Just a heads-up that I've seen your message but haven't had a chance to dig into it yet. I'll try to take a look next week! 😊

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

No branches or pull requests

2 participants