在matplotlib中创建多个图例标题
在matplotlib中,可以在图例里放多个“标题”吗?
我想要的效果是这样的:
Title 1
x label 1
o label2
Title 2
^ label 3
v label 4
...
比如我有四条曲线或者更多。如果我使用多个图例,手动调整它们的位置会很麻烦,很难对齐。
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)
...
...
图例的参数,比如 handletextpad
、handlelength
、columnspacing
等,会传给 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')
修改图例内容并移除不可见的图例标记
另一种方法是用仅有标签的方式替换掉任何有不可见图例标记的 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 的环境中适应起来会比使用其他方法更麻烦。
好的,我需要解决这个问题,但目前的答案对我来说不管用。在我的情况下,我不知道我需要在图例中放多少个标题。这取决于一些输入变量,所以我需要一种比手动设置标题位置更灵活的方法。在这里看了很多问题后,我找到了一个对我来说完美的解决方案,但也许还有更好的方法。
## 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))
我离这个问题最近的解决办法是创建一个空的代理艺术家。我的看法是,问题在于它们没有左对齐,但(空的)标记的空间还是存在的。
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()