How to find the rotation anchor point of Matplotlib Text objects (used for automatic resizing of ticklabels or other Text objects)?

Viewed 149

I'm trying to automatically scale Text objects (I'll focus on ticklabels in this question) in matplotlib plots such that they do not overlap. I need this for the (prototype) plotting module of a CLI program which dynamically outputs many plots of varying kinds and sizes.

Part of my current approach requires the rotation anchor of an arbitrarily rotated matplotlib.text.Text's bounding box, so the question of focus reads: how do I obtain the rotation anchor coordinates for a Bbox resulting from Text(...).get_window_extent() (preferably in the same coordinate system as the Bbox's coordinates are returnd)?

To prevent an XY problem, I'll provide more context:

Deriving robust heuristics for each of these plot types to determine the scale of Text objects which are not handled by plt.tight_layout() or constrained_layout seems infeasible. I am OK with redrawing being required to detect overlaps for now.

Animation to illustrate the problem: Ticklabel rescaling animation example

If Text objects are rotated, their bounding boxes each comprise a rectangle spanning from the lower left of the text, to the upper right of the text, which makes a square-ish rectangle not representative of the area actually covered by text (as opposed to the red/green boxes drawn in the animation). Simply using Bbox.intersection() thus doesn't work as desired.

I would like to use the red/green bounds to check for overlap. However, these were drawn using Text.set_bbox, and grabbing its extent still results in the same problem; a non-rotated square-ish shape for which I can't find the initial origin of rotation.

I tried using the shapely library's Polygon object per the suggestion in this SO thread, because I couldn't get it working using matplotlib transforms et al. before I got annoyed.

However, what I am doing now is probably worse and more complex than it needs to be:

  1. Store the rotation of the ticklabels before resizing.
  2. Get the Bbox extent for the ticklabels.
  3. Set their rotations to 0, and get the vertices of their original (non-rotated) bounding boxes.
  4. Convert the original Bbox corners/vertices to Polygon objects in the shapely library which supports calculation of intersection for rotated polygons.
  5. Rotate the ticklabels back to their original orientation.
  6. Calculate a (bad) estimate of the rotation anchor/origin used based on horizontal/vertical alignment properties.
  7. Rotate the Polygons around their estimated rotation origins/anchors.
  8. Calculate the intersections/overlap for the rotated polys.
  9. If there is no overlap or some iteration limit is reached, stop. Otherwise shrink the font by an amount depending on e.g. the difference in overlap surface compared to the last iteration, redraw the figure/texts objects, and go to step 1.

The code that does this is more coupled than it should be and it'd be too long to post here, so I'll include the snippet used to make the animation above instead (rotation is not taken into account properly here either, so it should be representative of the problem).

Now, I am looking for either a way to find the exact rotation anchor of a Text object to improve step 6, or another (less needlessly complicated) way to do these collision checks in the first place.

Update:

I'm looking through the source to find how this anchor position is determined internally. It seems that this happens in matplotlib.text.Text._get_layout() (starting at line 388, matplotlib version 3.4.3). Here, an offset is determined using corners of the unrotated box, so this may help. However it makes also makes use of a 'baseline' parameter which I don't quite understand the meaning of yet.

Code used for example:

#!/usr/bin/env python3
from __future__ import annotations

import matplotlib as mpl
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from matplotlib.axes import Axes
from matplotlib.text import Annotation
from matplotlib.transforms import Bbox

N_DATAPOINTS: int = 25
INITIAL_LABELSIZE: float = 10
N_FRAMES: int = 60


def calculate_overlap_area_bboxes(bbox_a: Bbox, bbox_b: Bbox) -> float:
    overlap_bbox: Bbox = Bbox.intersection(bbox_a, bbox_b)

    if overlap_bbox is None:
        intersecting_area = 0
    else:
        intersecting_area: float = \
            (overlap_bbox.xmax - overlap_bbox.xmin) * (overlap_bbox.ymax - overlap_bbox.ymin)

    return intersecting_area


def labelsize_update(ii: int,
                     ax: Axes,
                     area_annot: Annotation, overlap_annot: Annotation,
                     delta_fontsz: float = .25):
    # Get first ticklabels and their bounding boxes
    lbl_a, lbl_b = ax.get_xticklabels()[:2]
    first_bbox = lbl_a.get_window_extent().transformed(ax.transData.inverted())
    second_bbox = lbl_b.get_window_extent().transformed(ax.transData.inverted())
    
    # Check overlap
    overlap = calculate_overlap_area_bboxes(bbox_a=first_bbox, bbox_b=second_bbox)
    cur_ticklabels = ax.get_xticklabels()

    # There are of course more efficient ways to converge to a non-overlapping fontsize, but to keep
    # this example short-ish I'll simply make it a bit smaller every iteration as long as there is overlap.
    new_fontsize = cur_ticklabels[0].get_fontsize() - (delta_fontsz if overlap else 0)

    overlap_annot.set_text(
            '\n\n'.join([f"Visual overlap: {'YES' if new_fontsize > 5 else 'NO'}",
                         f"Overlap according to ticklabel Bbox extents: {'YES' if overlap else 'NO'}"]))

    area_annot.set_text(
            '\n'.join([f"Fontsize {new_fontsize: .2f}", f"Overlapping label area: {overlap:.5f}", "(square data units)"]))

    # Update ticklabel sizes
    for label in cur_ticklabels:
        # I can color its rotated bbox for this example, but don't know how to get its coordinates? :(
        label.set_bbox(dict(facecolor='w', edgecolor='r' if new_fontsize > 5 else 'g', alpha=.5, pad=-.1))
        label.set_fontsize(new_fontsize) if ii % N_FRAMES else label.set_fontsize(INITIAL_LABELSIZE)
    
    plt.tight_layout()
    return cur_ticklabels, area_annot, overlap_annot


def main():
    mpl.use("TkAgg")  # Force tkinter backend to allow single way of fixing interactive window size for sake of this example.

    # Prepare initial plot and annotations
    fig, ax = plt.subplots(figsize=(3, 2), dpi=250)
    ax.plot(range(N_DATAPOINTS), [1] * N_DATAPOINTS, alpha=0)
    ax.set_xticks(range(N_DATAPOINTS))
    ax.set_xticklabels([f"{ii} Lorem Ipsum" for ii in range(N_DATAPOINTS)],
                       rotation=45, rotation_mode='anchor', ha='right',
                       fontsize=INITIAL_LABELSIZE)
    ax.get_yaxis().set_visible(False)
    plt.tight_layout()

    area_annot = ax.annotate(f"Fontsize {INITIAL_LABELSIZE}. Overlapping label area: unknown",
                             xy=(.05, .1), xycoords=ax.transAxes,
                             fontsize=4, color='purple')
    overlap_annot = ax.annotate(f"Visual overlap: YES\n\nOverlap according to ticklabel Bbox extents: YES",
                                xy=(.5, .5), xycoords=ax.transAxes,
                                fontsize=5, fontweight='bold', ha='center')

    # Pass everything to animation and start it.
    _ = animation.FuncAnimation(fig, labelsize_update,
                                fargs=[ax, area_annot, overlap_annot],
                                interval=100, blit=False, repeat=True, frames=N_FRAMES)

    # Disable window resizing for the sake of this example.
    plt.get_current_fig_manager().window.resizable(False, False)
    plt.show()


if __name__ == '__main__':
    main()
0 Answers
Related