2025, Oct 16 02:19

Общая легенда для нескольких подграфиков в Matplotlib: надёжное размещение без смещений

Почему общая легенда в Matplotlib смещается при constrained layout и bbox_inches='tight', и как это исправить: отдельная ось под легенду через subplot_mosaic.

Разместить одну общую легенду для нескольких подграфиков так, чтобы она точно покрывала ширину областей построения, кажется простым — пока макет фигуры не начинает меняться у вас под ногами. Если вы привязываете легенду к bbox в координатах фигуры, механизмы раскладки Matplotlib могут сдвинуть оси и даже изменить размер фигуры во время сохранения — и легенда окажется не там, где вы задумывали.

Что мы хотим получить и почему это не работает

Нам нужна общая легенда, выровненная по центру над двумя подграфиками, ширина которой совпадает с суммарной шириной областей отрисовки. Кажется, что легенда на уровне всей фигуры с аккуратно вычисленным bbox_to_anchor должна сработать. Но при включённом layout="constrained" и использовании bbox_inches="tight" в savefig Matplotlib во время сохранения меняет положения осей и размер фигуры. Легенда, привязанная к координатам фигуры, в итоге смещается и становится слишком широкой.

Минимальный пример, демонстрирующий проблему

Ниже фрагмент кода, который вычисляет bbox, охватывающий ширину подграфиков, и использует его для легенды на уровне фигуры. Он также сохраняет изображение в нескольких размерах с сохранением пропорций.

import matplotlib.pyplot as plt

canvas, ax_grid = plt.subplots(nrows=1, ncols=2, figsize=(7, 4), layout="constrained")

# пример данных
xs = [1, 2, 3, 4, 5]
ys_a = [1, 2, 3, 4, 5]
ys_b = [5, 4, 3, 2, 1]
ax_grid[0].plot(xs, ys_a, label="Line 1")
ax_grid[1].plot(xs, ys_a, label="Line 1")
ax_grid[1].plot(xs, ys_b, label="Line 2")


def compute_span_bbox(ax_list):
    # BBox в координатах фигуры, охватывающий суммарную ширину подграфиков
    rects = [ax.get_position() for ax in ax_list]
    if rects:
        left = min(r.xmin for r in rects)
        right = max(r.xmax for r in rects)
        return (left, 0.0, right - left, 1.0)
    return None

# собрать уникальные дескрипторы/подписи со всех осей
all_handles, all_labels = [], []
for ax in ax_grid:
    hs, lbs = ax.get_legend_handles_labels()
    for h, lb in zip(hs, lbs):
        if lb not in all_labels:
            all_handles.append(h)
            all_labels.append(lb)

# легенда на уровне фигуры с bbox по ширине подграфиков
canvas.legend(
    all_handles,
    all_labels,
    loc="outside upper center",
    ncol=2,
    bbox_to_anchor=compute_span_bbox(ax_grid),
    mode="expand",
    borderaxespad=-1,
    columnspacing=1.0,
    handletextpad=0.4,
)

# сохранить в нескольких размерах, сохраняя соотношение сторон
sizes = [None, 3.16, 4.21]
base_w, base_h = canvas.get_size_inches()
ratio = base_h / base_w
for w in sizes:
    if w is not None:
        canvas.set_size_inches(w, w * ratio)
        canvas.legends[0].set_bbox_to_anchor(compute_span_bbox(ax_grid))

    canvas.savefig(
        f"mvr_figure_{w}.png" if w is not None else "mvr_figure.png",
        bbox_inches="tight",
    )

Почему легенда игнорирует ваш аккуратно рассчитанный bbox

Дрейф вызывают два взаимодействующих поведения. Во‑первых, при использовании constrained layout Matplotlib во время savefig подстраивает позиции осей, чтобы эффективнее использовать доступное пространство. Позиции осей, которые вы видите при вычислении bbox, — не те, что будут на этапе рендеринга. Во‑вторых, сохранение с bbox_inches="tight" меняет размер и форму фигуры, плотно облегая все объекты. Легенда, привязанная к координатам фигуры, затем позиционируется относительно уже изменившейся фигуры. Если убрать layout="constrained" и не использовать bbox_inches="tight", легенда окажется там, где велит bbox, — но частично выйдет за пределы фигуры, что как раз и побуждает включать эти режимы раскладки.

Надёжное решение: выделите легенде собственную ось

Не сопротивляйтесь системе раскладки — заставьте её работать на вас. Добавьте отдельную ось, предназначенную только для легенды. Тогда constrained layout будет согласованно размещать все три оси — контейнер легенды и две оси с графиками. Легенда заполнит свою ось, точно перекроет ширину графиков и останется по центру при изменении размеров.

import matplotlib.pyplot as plt

board, panel_map = plt.subplot_mosaic(
    "LL;AB", figsize=(7, 4), layout="constrained", height_ratios=[1, 10]
)

# пример данных
xs = [1, 2, 3, 4, 5]
ys_a = [1, 2, 3, 4, 5]
ys_b = [5, 4, 3, 2, 1]
panel_map["A"].plot(xs, ys_a, label="Line 1")
panel_map["B"].plot(xs, ys_a, label="Line 1")
panel_map["B"].plot(xs, ys_b, label="Line 2")

# собрать уникальные дескрипторы/подписи из A и B для легенды в L
legend_handles, legend_labels = [], []
for key in "AB":
    hs, lbs = panel_map[key].get_legend_handles_labels()
    for h, lb in zip(hs, lbs):
        if lb not in legend_labels:
            legend_handles.append(h)
            legend_labels.append(lb)

# разместить легенду внутри её оси и позволить ей растягиваться
panel_map["L"].legend(
    legend_handles,
    legend_labels,
    ncol=2,
    loc="center",
    mode="expand",
    borderaxespad=0,
    handletextpad=0.4,
)

# скрыть рамку оси легенды
panel_map["L"].axis("off")

# сохранить в нескольких размерах, сохраняя соотношение сторон
sizes = [None, 3.16, 4.21]
base_w, base_h = board.get_size_inches()
ratio = base_h / base_w
for w in sizes:
    if w is not None:
        board.set_size_inches(w, w * ratio)
    board.savefig(
        f"mvr_figure_{w}.png" if w is not None else "mvr_figure.png",
    )

При экспорте очень маленьких фигур constrained layout может предупреждать, что размеры осей схлопываются. Для экстремального уменьшения это ожидаемо:

UserWarning: constrained_layout не применён, потому что размеры осей схлопнулись до нуля. Попробуйте сделать фигуру больше или уменьшить оформления осей.

Остальные размеры отображаются как задумано.

Зачем это понимать

Расположение легенды тонко взаимодействует с раскладкой и экспортом. Constrained layout корректирует оси во время savefig, а bbox_inches="tight" меняет размер холста, подгоняя его под объекты. Любой объект, позиционируемый в координатах фигуры, включая легенды с привязкой bbox, может сместиться в окончательном файле. Ось для легенды обходит это ограничение: движок раскладки согласованно управляет всеми позициями.

Выводы

Если нужна общая легенда, идеально выровненная с сеткой подграфиков, относитесь к ней как к полноценному участнику макета. Поместите её на отдельную ось, доверьте constrained layout расстановку отступов и не полагайтесь на привязку к координатам фигуры, которая во время сохранения может устареть. Если необходимо отлаживать привязки, тестируйте без constrained layout и без tight-обрезки, чтобы проверить сам anchor, а для финальных рисунков переходите на вариант с отдельной осью под легенду.

Статья основана на вопросе с StackOverflow от BernhardWebstudio и ответе RuthC.