2025, Oct 31 22:47

Как убрать лишние отступы в проекциях cartopy и matplotlib

Показываем, почему xlim/ylim в GeoAxes cartopy дают лишние поля, и как получить плотную компоновку: ax.set_global и сохранение с bbox_inches='tight'. Пример.

При построении карт неба с matplotlib и cartopy часто раздражает избыток пустого пространства вокруг проекции и цветовой шкалы. Карта так и не заполняет холст, а ужесточение отступов не помогает. В этом руководстве разбираемся, почему так происходит, и как получать компактные изображения без подбора отступов под каждую проекцию.

Как воспроизвести слишком большие поля

Проблема становится очевидной, когда задаём xlim и ylim на осях карты и затем сохраняем рисунок. Карта получает отступы со всех сторон, и разные проекции дают разные поля — даже при одинаковой компоновке.

import cartopy.crs as ccrs
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.layout_engine import ConstrainedLayoutEngine
def run():
    astro_globe = ccrs.Globe(datum=None, ellipse=None,
                             semimajor_axis=180/np.pi,
                             semiminor_axis=180/np.pi)
    data_crs = ccrs.PlateCarree(globe=astro_globe)
    lon_grid, lat_grid = np.mgrid[-179.5:180:1, -89.5:90:1]
    noise_img = perlin2d(lon_grid.shape, (1, 1))
    for tag, projection_cls in [("ee", ccrs.EqualEarth),
                                ("mw", ccrs.Mollweide),
                                ("lc", ccrs.LambertCylindrical)]:
        try:
            fig_obj, map_ax = plt.subplots(
                figsize=(20, 10),
                layout=ConstrainedLayoutEngine(
                    h_pad=0, w_pad=0, hspace=0, wspace=0
                ),
                subplot_kw={
                    "xlim": (-180, 180),
                    "ylim": (-90, 90),
                    "projection": projection_cls(globe=astro_globe)
                },
            )
            cset = map_ax.contourf(lon_grid, lat_grid, noise_img,
                                   transform=data_crs,
                                   cmap="Greys")
            fig_obj.colorbar(cset, shrink=0.5, pad=0.02)
            fig_obj.savefig(f"layout_test_{tag}.png")
        finally:
            plt.close(fig_obj)
def perlin2d(shape, res):
    def ease(t):
        return 6*t**5 - 15*t**4 + 10*t**3
    delta = (res[0] / shape[0], res[1] / shape[1])
    d = (shape[0] // res[0], shape[1] // res[1])
    grid = np.mgrid[0:res[0]:delta[0],0:res[1]:delta[1]].transpose(1, 2, 0) % 1
    angles = 2*np.pi*np.random.rand(res[0]+1, res[1]+1)
    grads = np.dstack((np.cos(angles), np.sin(angles)))
    g00 = grads[0:-1,0:-1].repeat(d[0], 0).repeat(d[1], 1)
    g10 = grads[1:,0:-1].repeat(d[0], 0).repeat(d[1], 1)
    g01 = grads[0:-1,1:].repeat(d[0], 0).repeat(d[1], 1)
    g11 = grads[1:,1:].repeat(d[0], 0).repeat(d[1], 1)
    n00 = np.sum(grid * g00, 2)
    n10 = np.sum(np.dstack((grid[:,:,0]-1, grid[:,:,1])) * g10, 2)
    n01 = np.sum(np.dstack((grid[:,:,0], grid[:,:,1]-1)) * g01, 2)
    n11 = np.sum(np.dstack((grid[:,:,0]-1, grid[:,:,1]-1)) * g11, 2)
    t = ease(grid)
    n0 = n00*(1-t[:,:,0]) + t[:,:,0]*n10
    n1 = n01*(1-t[:,:,0]) + t[:,:,0]*n11
    return np.sqrt(2)*((1-t[:,:,1])*n0 + t[:,:,1]*n1)
run()

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

Главная ловушка в том, что xlim и ylim у GeoAxes из cartopy относятся к декартовым координатам осей в пространстве проекции, а не к долготе и широте. Это легко увидеть интерактивно: откройте рисунок через plt.show и поводите курсором по холсту. В интерфейсе будут показаны два набора значений: сырые x и y в координатах осей и соответствующие долгота и широта. В QtAgg они отображаются в правом верхнем углу, в TkAgg — в правом нижнем. Для EqualEarth видимый «отпечаток» карты охватывает примерно ±155 по x и ±75 по y. Принудительные границы x до ±180 и y до ±90 не совпадают с контуром проекции, поэтому добавляется дополнительное пространство, чтобы сохранить форму. Отсюда нежелательные горизонтальные пустоты и несогласованные поля между проекциями. Оставшийся вертикальный зазор возникает из‑за того, что ограничивающая рамка фигуры всё ещё больше объединения всех объектов на холсте.

Рабочее решение для плотной компоновки карт

Перестаньте задавать xlim и ylim, попросите оси показать весь глобус и сохраняйте с ограничивающей рамкой, которая плотно обхватывает содержимое. Удаление явных пределов убирает горизонтальные поля. Вызов ax.set_global гарантирует, что проекция показывает всю карту, а не слегка сплющенный вид возле экватора. Сохранение с bbox_inches='tight' подрезает фигуру по объектам и убирает оставшееся вертикальное пустое пространство.

import cartopy.crs as ccrs
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.layout_engine import ConstrainedLayoutEngine
def run():
    astro_globe = ccrs.Globe(datum=None, ellipse=None,
                             semimajor_axis=180/np.pi,
                             semiminor_axis=180/np.pi)
    data_crs = ccrs.PlateCarree(globe=astro_globe)
    lon_grid, lat_grid = np.mgrid[-179.5:180:1, -89.5:90:1]
    noise_img = perlin2d(lon_grid.shape, (1, 1))
    for tag, projection_cls in [("ee", ccrs.EqualEarth),
                                ("mw", ccrs.Mollweide),
                                ("lc", ccrs.LambertCylindrical)]:
        try:
            fig_obj, map_ax = plt.subplots(
                figsize=(20, 10),
                layout=ConstrainedLayoutEngine(
                    h_pad=0, w_pad=0, hspace=0, wspace=0
                ),
                subplot_kw={
                    "projection": projection_cls(globe=astro_globe)
                },
            )
            map_ax.set_global()
            cset = map_ax.contourf(lon_grid, lat_grid, noise_img,
                                   transform=data_crs,
                                   cmap="Greys")
            fig_obj.colorbar(cset, shrink=0.5, pad=0.02)
            fig_obj.savefig(
                f"layout_test_{tag}.png",
                bbox_inches="tight",
                pad_inches="layout",
            )
        finally:
            plt.close(fig_obj)
def perlin2d(shape, res):
    def ease(t):
        return 6*t**5 - 15*t**4 + 10*t**3
    delta = (res[0] / shape[0], res[1] / shape[1])
    d = (shape[0] // res[0], shape[1] // res[1])
    grid = np.mgrid[0:res[0]:delta[0],0:res[1]:delta[1]].transpose(1, 2, 0) % 1
    angles = 2*np.pi*np.random.rand(res[0]+1, res[1]+1)
    grads = np.dstack((np.cos(angles), np.sin(angles)))
    g00 = grads[0:-1,0:-1].repeat(d[0], 0).repeat(d[1], 1)
    g10 = grads[1:,0:-1].repeat(d[0], 0).repeat(d[1], 1)
    g01 = grads[0:-1,1:].repeat(d[0], 0).repeat(d[1], 1)
    g11 = grads[1:,1:].repeat(d[0], 0).repeat(d[1], 1)
    n00 = np.sum(grid * g00, 2)
    n10 = np.sum(np.dstack((grid[:,:,0]-1, grid[:,:,1])) * g10, 2)
    n01 = np.sum(np.dstack((grid[:,:,0], grid[:,:,1]-1)) * g01, 2)
    n11 = np.sum(np.dstack((grid[:,:,0]-1, grid[:,:,1]-1)) * g11, 2)
    t = ease(grid)
    n0 = n00*(1-t[:,:,0]) + t[:,:,0]*n10
    n1 = n01*(1-t[:,:,0]) + t[:,:,0]*n11
    return np.sqrt(2)*((1-t[:,:,1])*n0 + t[:,:,1]*n1)
run()

Почему важно понимать это

Проекции cartopy живут в собственных декартовых системах координат, которые нелинейно соотносятся с долготой и широтой. Если воспринимать xlim и ylim как границы по lon/lat, вы принуждаете экстенты, не совпадающие с естественным «отпечатком» проекции. В результате появляется лишнее пустое место — движок компоновки пытается сохранить соотношение сторон. Осознание этой разницы даёт предсказуемый контроль над композицией. Используйте вывод координат курсора в GUI, чтобы сопоставлять x и y с lon и lat — так различие становится наглядным. Для EqualEarth видимый контур около ±155 на ±75, поэтому требование ±180 на ±90 просто не может быть выполнено без добавочных отступов.

Итоги

Чтобы держать карты неба компактными при любых проекциях, не задавайте вручную xlim и ylim на GeoAxes, запрашивайте весь глобус через ax.set_global и позволяйте bbox_inches="tight" обрезать остаточные поля при сохранении. Эта комбинация убирает несогласованные отступы, работает с цветовой шкалой и избавляет от подбора «магических чисел» для каждой проекции. Если нужно посмотреть, как координаты осей связаны с долготой и широтой, откройте рисунок интерактивно и наблюдайте оба показания при перемещении курсора.

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