在matplotlib中创建多个图例标题

11 投票
3 回答
6685 浏览
提问于 2025-04-18 13:34

在matplotlib中,可以在图例里放多个“标题”吗?

我想要的效果是这样的:

Title 1
x label 1
o label2

Title 2
^ label 3
v label 4

...

比如我有四条曲线或者更多。如果我使用多个图例,手动调整它们的位置会很麻烦,很难对齐。

3 个回答

3

Matplotlib 只支持一个图例标题,但有时候我想要多个标题,并且将标签与图例框的左边对齐时,它们看起来更像标题,而不是其他带有图例标记的标签。我找到了一些解决办法:

使用 Text.set_position() 移动标签

这个方法和 @M.O. 的回答类似。关键在于 Text.set_position() 使用的是 显示坐标,所以我们需要准确计算出标签要偏移多少像素,而不能像给 plt.legend() 那样直接用字体大小单位。通过查看 matplotlib.legend.Legend._init_legend_box(),我们可以了解涉及到哪些类和参数。Matplotlib 会创建一个 HPacker 和 VPacker 对象的树状结构:

VPacker
    Text (title, invisible if unused)
    HPacker (columns)
        VPacker (column)
            HPacker (row of (handle, label) pairs)
                DrawingArea
                    Artist (handle)
                TextArea
                    Text (label)
            ...
        ...

图例的参数,比如 handletextpadhandlelengthcolumnspacing 等,会传给 HPacker、VPacker 和 DrawingArea 对象来控制间距。我们关注的间距参数是“字体大小单位”,所以如果 handlelength=2,那么图例标记的宽度就是 2 * fontsize 点。而“点”是一个旧的排版单位,等于 1/72 英寸。通过查看图形的 DPI,我们可以将点转换为像素,但一些 Matplotlib 的后端,比如 SVG,不使用像素,所以我们想用 Renderer.points_to_pixels() 来代替自己计算。

回到 _init_legend_box(),看起来标签会被偏移 handlelength + handletextpad,但如果深入研究 HPacker,我们会发现它会无条件地在每个子元素周围添加一个像素的填充,所以我们还需要再加 2 个像素:每个图例标记的两侧各一个。

最后,我们需要一种方法来标记图例条目为标题,设置 visible=False 在图例标记上似乎不错,因为图例标记必须是 Artist(或其子类)实例,而每个 Artist 都有 visible 属性。

代码示例:

import matplotlib as mpl

def style_legend_titles_by_setting_position(leg: mpl.legend.Legend, bold: bool = False) -> None:
    """ Style legend "titles"

    A legend entry can be marked as a title by setting visible=False. Titles
    get left-aligned and optionally bolded.
    """
    # matplotlib.offsetbox.HPacker unconditionally adds a pixel of padding
    # around each child.
    hpacker_padding = 2

    for handle, label in zip(leg.legendHandles, leg.texts):
        if not handle.get_visible():
            # See matplotlib.legend.Legend._init_legend_box()
            widths = [leg.handlelength, leg.handletextpad]
            offset_points = sum(leg._fontsize * w for w in widths)
            offset_pixels = leg.figure.canvas.get_renderer().points_to_pixels(offset_points) + hpacker_padding
            label.set_position((-offset_pixels, 0))
            if bold:
                label.set_fontweight('bold')

使用示例:

import matplotlib as mpl
from matplotlib.patches import Patch
import matplotlib.pyplot as plt

def make_legend_with_subtitles() -> mpl.legend.Legend:
    legend_contents = [
        (Patch(visible=False), 'Colors'),
        (Patch(color='red'), 'red'),
        (Patch(color='blue'), 'blue'),

        (Patch(visible=False), ''),  # spacer

        (Patch(visible=False), 'Marks'),
        (plt.Line2D([], [], linestyle='', marker='.'), 'circle'),
        (plt.Line2D([], [], linestyle='', marker='*'), 'star'),
    ]
    fig = plt.figure(figsize=(2, 2))
    leg = fig.legend(*zip(*legend_contents))
    return leg

leg = make_legend_with_subtitles()
style_legend_titles_by_setting_position(leg)
leg.figure.savefig('set_position.png')

使用 set_position 的多标题图例

修改图例内容并移除不可见的图例标记

另一种方法是用仅有标签的方式替换掉任何有不可见图例标记的 HPacker:

def style_legend_titles_by_removing_handles(leg: mpl.legend.Legend) -> None:
    for col in leg._legend_handle_box.get_children():
        row = col.get_children()
        new_children: list[plt.Artist] = []
        for hpacker in row:
            if not isinstance(hpacker, mpl.offsetbox.HPacker):
                new_children.append(hpacker)
                continue
            drawing_area, text_area = hpacker.get_children()
            handle_artists = drawing_area.get_children()
            if not all(a.get_visible() for a in handle_artists):
                new_children.append(text_area)
            else:
                new_children.append(hpacker)
        col._children = new_children

leg = make_legend_with_subtitles()
style_legend_titles_by_removing_handles(leg)
leg.figure.savefig('remove_handles.png')

通过移除图例标记实现多标题图例

不过,修改图例内容感觉不太稳定。Seaborn 有一个函数 adjust_legend_subtitles(),它是将 DrawingArea 的宽度设置为 0,如果你同时将 handletextpad=0,标签几乎会左对齐,只是 DrawingArea 周围的 HPacker 填充会让标签右移 2 像素。

创建多个图例并将内容合并在一起

Seaborn 的最新方法是创建多个图例对象,为每个图例使用 title 参数,将内容合并成一个主图例,然后只将这个主图例注册到图形中。我喜欢这个方法,因为它让 Matplotlib 控制标题的样式,而且你可以指定一个比添加不可见图例标记更清晰的接口,但我觉得在非 Seaborn 的环境中适应起来会比使用其他方法更麻烦。

6

好的,我需要解决这个问题,但目前的答案对我来说不管用。在我的情况下,我不知道我需要在图例中放多少个标题。这取决于一些输入变量,所以我需要一种比手动设置标题位置更灵活的方法。在这里看了很多问题后,我找到了一个对我来说完美的解决方案,但也许还有更好的方法。

## this is what changes sometimes for me depending on how the user decided to input
parameters=[2, 5]


## Titles of each section
title_2 = "\n$\\bf{Title \, parameter \, 2}$"
title_4 = "\n$\\bf{Title \, parameter \, 4}$"
title_5 = "\n$\\bf{Title \, parameter \, 5}$"



def reorderLegend(ax=None, order=None):
    handles, labels = ax.get_legend_handles_labels()
    info = dict(zip(labels, handles))

    new_handles = [info[l] for l in order]
    return new_handles, order


#########
### Plots
fig, ax = plt.subplots(figsize=(10, 10))
ax.set_axis_off()

## Order of labels
all_labels=[]
if 2 in parameters:
    ax.add_line(Line2D([], [], color="none", label=title_2)) 
    all_labels.append(title_2)
    #### Plot your stuff below header 2
    #### Append corresponding label to all_labels



if 4 in parameters:
    ax.add_line(Line2D([], [], color="none", label=title_4))
    all_labels.append(title_4)
    #### Plot your stuff below header 4
    #### Append corresponding label to all_labels

if 5 in parameters:
    ax.add_line(Line2D([], [], color="none", label=title_5))
    all_labels.append(title_5)
    #### Plot your stuff below header 5
    #### Append corresponding label to all_labels

## Make Legend in correct order
handles, labels = reorderLegend(ax=ax, order=all_labels)
leg = ax.legend(handles=handles, labels=labels, fontsize=12, loc='upper left', bbox_to_anchor=(1.05, 1), ncol=1, fancybox=True, framealpha=1, frameon=False)

## Move titles to the left 
for item, label in zip(leg.legendHandles, leg.texts):
    if label._text  in [title_2, title_4, title_5]:
        width=item.get_window_extent(fig.canvas.get_renderer()).width
        label.set_ha('left')
        label.set_position((-2*width,0))

作为一个例子,我得到了以下这个图例(图片的其他部分我已经裁剪掉了)。
在这里输入图片描述

7

我离这个问题最近的解决办法是创建一个空的代理艺术家。我的看法是,问题在于它们没有左对齐,但(空的)标记的空间还是存在的。

from matplotlib.patches import Rectangle
import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(0, 1, 100)

# the comma is to get just the first element of the list returned
plot1, = plt.plot(x, x**2) 
plot2, = plt.plot(x, x**3)

title_proxy = Rectangle((0,0), 0, 0, color='w')

plt.legend([title_proxy, plot1, title_proxy, plot2], 
           ["$\textbf{title1}$", "label1","$\textbf{title2}$", "label2"])
plt.show()

撰写回答