1. 从“看结果”这个真实需求说起:为什么要拼接图像
做深度学习训练,尤其是图像分类这类任务,常规操作里有一件特别琐碎但又绕不开的事:模型训练完之后,得把输入图像和模型预测的结果摆在一起,人眼直接看一遍。如果只打印一个准确率数字,你根本不知道模型到底错在了哪里——它是不是把“T恤”误认成了“衬衫”?把“靴子”判断成了“短靴”?这时候你需要的不是文字描述,而是一张把多张图像拼起来的大图,一眼扫过去就能对比。
标题里以FashionMNIST为例,是很聪明的选择。这个数据集是灰度图,尺寸小(28x28),类别多(10类),又不像MNIST手写数字那么单调,拼出来能直观看到每张图的纹理差异。更重要的是,FashionMNIST在PyTorch生态里几乎是“开箱即用”的,torchvision里自带下载和预处理接口,不需要自己写Dataset和Dataloader,非常适合把注意力完全放在“图像拼接”这件事本身。
这篇文章面向的读者大概是两类:一类是刚接触PyTorch没多久,想学会怎么把训练结果可视化,却卡在了subplot和subplots这两个API上的初学者;另一类是有一定基础,但每次都是临时百度“matplotlib 拼接图片代码”,用完就忘,没搞懂subplot和subplots到底差在哪的“调包侠”。我尽量把两条线都讲透——既讲清楚API层面的区别,也把实际拼接图片时的完整代码、数据转换、显示大小、保存设置这些细节全部覆盖到。
2. 数据准备:Pytorch加载FashionMNIST时最容易忽略的三个细节
拼接图片的第一步不是调用matplotlib,而是保证从PyTorch拿到的数据能顺利变成matplotlib能显示的格式。这一步看似简单,实际上有三个坑很容易踩。
2.1 transform参数决定数据格式,中文文档常把这一步讲得太轻描淡写
加载FashionMNIST的标准写法是:
import torch from torchvision import datasets, transforms train_data = datasets.FashionMNIST( root="./data", train=True, download=True, transform=transforms.ToTensor() )ToTensor()这个transform做了两件事:把PIL图像从形状(H, W)变成(C, H, W),也就是(1, 28, 28),同时把像素值从0-255的整数缩放到0-1的浮点数。这两件事如果不做,后面直接拿原始PIL图像数据来喂matplotlib,显示出来的结果会非常奇怪。
初学者最容易在这里犯的错是:下载完数据后直接data[0][0]取出来就往plt.imshow()里塞,结果发现要么报维度错误,要么显示出一片乱七八糟的颜色。原因是train_data[0]返回的是元组(tensor, label),而train_data[0][0]是形状为(1, 28, 28)的三维tensor,matplotlib的imshow接收不了这种带通道维的灰度图。
2.2 CHW到HWC转换:单通道图像用squeeze,三通道必须用permute
PyTorch的tensor格式是(C, H, W),而matplotlib的imshow函数默认接收(H, W)或者(H, W, C)格式。对FashionMNIST这种单通道灰度图,最直接的转换是:
img_tensor = train_data[0][0] # shape: (1, 28, 28) img_np = img_tensor.squeeze().numpy() # shape: (28, 28)squeeze()会把维度为1的通道维压缩掉,从(1, 28, 28)变成(28, 28),然后.numpy()把tensor转成numpy数组。这个顺序不能反过来——先numpy()再squeeze()也行,但很多人会随手写img_tensor.numpy().squeeze(),效果一样,就是绕了一点。
如果你用的是CIFAR-10这类三通道彩色图,就不能squeeze了,得用permute(1, 2, 0)把(C, H, W)调成(H, W, C):
img_np = img_tensor.permute(1, 2, 0).numpy()这一步想明白了,不管是灰度图还是彩色图,拼接图像时都不会卡在数据格式上。
2.3 显示“全黑”或颜色怪异,十有八九是vmin/vmax的问题
还有一个很容易被忽略的细节:ToTensor()把像素归一化到0-1范围后,如果训练时又做了额外归一化(比如均值0.5、方差0.5),图像数据里就会出现负值。直接把负值扔给imshow(),默认的vmin和vmax会按数据的最小值和最大值自动推断,结果往往是整张图灰灰暗暗、对比度极差。更常见的情况是很多人在自己的预处理pipeline里写了transforms.Normalize((0.5,), (0.5,)),后面可视化时忘了这个步骤,然后对着全黑的图发呆。
这不是变换写错了,而是显示时没有恢复像素范围。最简单的处理是:
# 如果数据经过Normalize,显示前先反归一化 img_np = img_np * 0.5 + 0.5 # 或者直接强制设定显示范围 plt.imshow(img_np, cmap="gray", vmin=0, vmax=1)我用vmin/vmax这个参数的频率非常高,尤其是在失手用错数据时,它能救回来很多“看起来全黑”的情况。后面拼接统一输出时,建议在imshow里显式写上vmin=0, vmax=1,这样无论数据来源是什么,显示效果都稳定。
3. subplot 和 subplots:同一个目的的两种姿势,到底差在哪
这是整篇文章的核心问题。网上的教程经常把subplot和subplots混着用,搞得人很懵:明明都能拼图,为什么要有两套API?两者到底该选哪个?
3.1 API设计理念:一个是“逐个子图创建”,一个是“一次性布局网格”
plt.subplot(nrows, ncols, index)是matplotlib早期pyplot接口的风格,它的工作方式是“创建一个子图,然后把这个子图设为当前axes”。每次调用只创建并激活一个子图,如果你想创建2行3列共6个子图,你得调用6次:
plt.figure(figsize=(12, 4)) plt.subplot(2, 3, 1) plt.imshow(...) plt.subplot(2, 3, 2) plt.imshow(...) # 依次类推... plt.subplot(2, 3, 6) plt.imshow(...)每一个调用的第三个参数index表示位置,从左上角开始,从左到右、从上到下编号。这种接口最大的问题是:每个子图的绘制代码都独立出现,相互之间没有数据结构上的关联,想批量操作所有子图(比如统一关闭坐标轴)只能手动循环索引。
而plt.subplots(nrows, ncols)是面向对象接口的推荐入口。它一次性创建整个子图网格,同时返回两个对象:fig(整个画布)和axes(子图数组)。用numpy的索引方式访问每个子图:
fig, axes = plt.subplots(2, 3, figsize=(12, 4)) # axes是一个shape为(2, 3)的numpy数组 axes[0][0].imshow(...) axes[0][1].imshow(...)批量操作时优势特别明显:
for ax in axes.flat: ax.axis("off")axes.flat是把二维数组拍平成一维迭代器,一行代码就能处理所有子图。这和subplot那种“一个一个写”的方式比,代码量和可维护性都不是一个量级。
3.2 返回值差异导致代码风格分岔:单个Axes vs Axes数组
这是两者最本质的区别。subplot返回的是单个Axes对象,subplots返回的是Axes对象的数组(numpy.ndarray)。
这个差异在“只创建一个画布一个子图”时体现得不够明显,一旦子图数量变多,处理方式就完全不同了。用subplot时,如果你想把6张图存到一个列表里统一设置,你得手动维护列表:
ax_list = [] ax_list.append(plt.subplot(2, 3, 1)) ax_list.append(plt.subplot(2, 3, 2)) # ...用subplots则天生就是数组结构:
fig, axes = plt.subplots(2, 3) for ax in axes.flat: ax.set_title("demo")还有一个我经常用到的情况:当子图数量不是严格矩形,比如第一行1个、第二行2个时,subplots的“规整网格”反而不够灵活,subplot可以逐个指定位置,甚至可以用span跨行跨列。但这种场景在“拼接若干张图像”这种需求里很少出现——拼图基本都是规整的矩形网格,所以subplots的使用频率远高于subplot。
3.3 实际选型建议:拼接图片用subplots,理解原理再回头看看subplot
如果你只是想把一批图像摆成网格,我的建议是无脑用plt.subplots。原因有三个:第一,批量操作方便,axes.flat循环直接覆盖所有子图;第二,返回的fig对象可以直接配合fig.tight_layout()等布局控制方法,不需要额外获取“当前figure”;第三,subplots支持更多参数,比如squeeze=False强制保持二维数组形状,或者sharex=True共享坐标轴刻度,这些在subplot里都得绕路实现。
但我不建议完全忽略subplot。它背后其实隐藏了matplotlib的坐标轴机制——每次subplot被调用,都会在figure上新建一个axes区域。理解了这个机制,你就能明白为什么subplot可以“一个接一个”地创建不同大小的子图,也能理解subplots的gridspec参数是怎么回事。简单说:subplot是更底层的逐个操作,subplots是对常见需求的批量封装。
| 对比维度 | plt.subplot | plt.subplots |
|---|---|---|
| 创建方式 | 每次调用创建一个子图 | 一次创建整个网格 |
| 返回值 | 单个Axes对象 | (fig, axes数组) |
| 批量操作 | 需要手动维护索引 | axes.flat直接遍历 |
| 灵活布局 | 相对灵活,可跨行跨列 | 适合规整矩形布局 |
| 推荐场景 | 布局特殊、逐图处理 | 图像拼接、规整网格展示 |
4. 拼接全流程代码:先实现,再逐行拆解
先把完整代码贴出来,这份代码我用的频率极高,几乎成了个人可视化工具箱里的固定组件。以FashionMNIST为例,从Dataloader里取16张图,拼成4行4列的大图,并自动给每个子图加上类别标签。
import torch from torchvision import datasets, transforms import matplotlib.pyplot as plt import math # 1. 加载FashionMNIST数据集 train_data = datasets.FashionMNIST( root="./data", train=True, download=True, transform=transforms.ToTensor() ) # 2. 类别标签(FashionMNIST的10个类) class_names = [ "T-shirt/top", "Trouser", "Pullover", "Dress", "Coat", "Sandal", "Shirt", "Sneaker", "Bag", "Ankle boot" ] # 3. 取前16张图像及其标签 num_images = 16 images = [] labels = [] for i in range(num_images): img_tensor, label = train_data[i] images.append(img_tensor.squeeze().numpy()) # (28, 28) labels.append(class_names[label]) # 4. 计算网格行列数:默认正方形网格 cols = int(math.sqrt(num_images)) rows = math.ceil(num_images / cols) # 5. 用subplots创建网格 fig, axes = plt.subplots(rows, cols, figsize=(cols * 2, rows * 2)) # 6. 填入图像 for idx, ax in enumerate(axes.flat): if idx < num_images: ax.imshow(images[idx], cmap="gray", vmin=0, vmax=1) ax.set_title(labels[idx], fontsize=10) ax.axis("off") # 7. 调整布局 plt.tight_layout() plt.show()4.1 核心步骤的为什么:行列计算与figsize
第4步里用math.sqrt(num_images)算列数,然后取整得到行数,这个逻辑是为了让网格尽量接近正方形。16张图开根号是4,正好得到4x4;如果你取10张图,开根号约3.16,向上取整到列数4,行数ceil(10/4)=3,网格就是3行4列,多余的2个位置在第6步里被idx < num_images过滤掉,只关闭坐标轴、不显示图像。
figsize的设置是“每张子图约2英寸宽”的估算。28x28的灰度图本身很小,如果figsize太大会浪费空间,太小又会糊成一团。传(cols*2, rows*2)的好处是网格越大,画布自动越大,每张子图的物理尺寸保持相对稳定。这个值我一般会在2到3之间调,数据量大时可以适当减小。
4.2 一种更简洁的写法:torchvision自带的make_grid让你的代码减半
拼图这件事,PyTorch其实内置了一个很省事的方法,不写出来有点埋没它。torchvision.utils.make_grid可以把一批图像直接拼成一张网格图,返回的还是tensor,一行代码保存成图片:
from torchvision.utils import make_grid import torchvision.transforms.functional as F # 取16张图像(保留C,H,W格式) img_tensors = [train_data[i][0] for i in range(16)] grid_tensor = make_grid(img_tensors, nrow=4, padding=2, normalize=True) # 转成numpy并调整维度 grid_img = grid_tensor.permute(1, 2, 0).numpy() plt.imshow(grid_img) plt.axis("off") plt.show()make_grid的好处是不需要手动spawn网格和循环填充,nrow参数直接控制每行几张。normalize=True会按整个网格的像素范围做归一化,显示效果往往比自己拼的更好看。
但这个方法有两个局限:一是datasets里自带的make_grid默认支持4D batch输入,如果你只传3D单张不行,需要手动unsqueeze(0)加batch维;二是它跟matplotlib的配合是个“一次性输出”,没法灵活控制每张图的标题。所以我的习惯是:快速看一眼用make_grid,想写报告、加标题、精细控制布局就回到subplots方案。
5. 让拼接图更像“作品”:坐标轴、标题、留白与保存的处理细节
拼接图像做到能显示出来,只是完成了一半。很多时候你会发现拼出来的图要么每个子图挤在一起,坐标轴刻度线乱糟糟的,要么保存到本地后尺寸和显示效果对不上。这些细节处理好了,可视化的专业感一下就上去了。
5.1 关闭坐标轴和网格残留,是拼接图的第一优先级
ax.axis("off")这句代码在拼接图像里几乎是无条件的——除非你真的需要展示坐标轴刻度,比如在做像素级对比。很多人拼完图忘了关坐标轴,每个子图下方多了一串0到25的刻度标签,整个版面瞬间变得很“毛坯房”。如果想让图像的边缘也紧凑衔接,可以用ax.set_xticks([])加ax.set_yticks([])代替,效果更干净。
还有一种情况是在数据集迭代过程中,有些子图没有内容(比如16张图拼成3行4列最后缺2个格子),此时除了关坐标轴,最好把边框也隐藏掉,避免留出突兀的空白框。可以用ax.set_visible(False),效果比axis("off")更彻底。
5.2 标题是对比分析的灵魂:set_title的位置与内容设计
在FashionMNIST这种多分类任务里,给每张子图配上真实的类别标签,观察同一个类别内图像的差异,或者对比不同类别的纹理特征,标题的阅读价值甚至高于图像本身。ax.set_title(labels[idx], fontsize=10)大家都会写,但有几个tips:
- 如果标题太长,比如“T-shirt/top”,可以加
rotation=0并调小fontsize,否则会和图像重叠。 - 想显示预测值和真实值两个信息时,可以在同一个标题里用两行:
ax.set_title(f"{true_label}\npred: {pred_label}", fontsize=9)。 - 批量设置时注意标题的字体大小和图像尺寸的比例,28x28的图像本身很小,标题过大反而喧宾夺主。
5.3 tight_layout、subplots_adjust与保存分辨率
拼接完成后plt.tight_layout()几乎是必须的,它会自动调整子图之间的间距,防止标题重叠或坐标轴被截断。如果tight_layout之后还嫌间距太大或者太小,就得手动手动调整plt.subplots_adjust(wspace=0.05, hspace=0.05),把子图之间的水平垂直间隙压到极小值,这样拼出来的大图才有“无缝拼接”的紧凑感。
保存到本地时我常用:
fig.savefig("fashion_mnist_grid.png", dpi=150, bbox_inches="tight")bbox_inches="tight"会把周围的留白裁掉,按内容实际大小保存,这个参数比手动调figsize更靠谱。dpi=150在报告和网页展示时基本够用,如果是要印刷或者发论文,建议提到300。
5.4 显示整套数据:从Dataloader到子图索引的对接
实战中这批图像往往不是简单的前16张,而是来自DataLoader的一个batch。DataLoader返回的(batch_images, batch_labels)中batch_images的形状是(B, C, H, W),B就是batch size。拼接函数可以直接设计成接收batch数据:
def show_batch(images, labels, class_names=None, cols=4): batch_size = images.size(0) rows = math.ceil(batch_size / cols) fig, axes = plt.subplots(rows, cols, figsize=(cols*2, rows*2)) axes = axes.flatten() if rows * cols > 1 else [axes] for idx, ax in enumerate(axes): if idx < batch_size: img = images[idx].squeeze().numpy() ax.imshow(img, cmap="gray", vmin=0, vmax=1) if class_names is not None: ax.set_title(class_names[labels[idx]], fontsize=10) ax.axis("off") plt.tight_layout() return fig, axes这里有个细节:如果batch size是1,plt.subplots(1, 1)返回的axes是单个对象而不是数组,直接axes.flatten()会报错,所以加了个判断。这种边界条件最容易在复用代码时爆雷,提前处理好能省很多事。
6. 踩坑记录:我在拼接FashionMNIST时碰到的三个典型问题
这部分是我自己反复踩过的坑,分享出来的价值往往比主流程代码更大。每一个都曾经让我调试到怀疑人生,后来发现都是些很基础的细节。
6.1 灰度图变成了“大花脸”:忘记指定cmap
第一次拼FashionMNIST的时候,我把数据转换都做好了,subplots也创建了,imshow里没写cmap参数,结果显示出来全是紫绿相间的伪彩色图。一开始我还以为是数据加载问题,后来才反应过来:matplotlib的imshow默认不指定cmap时,对二维灰度数组用的是viridis彩色映射,不是灰色。
这个坑的教训是:显示灰度图像时,cmap="gray"几乎永远是必写参数。唯一可能例外的是你确实想用伪彩色增强对比,比如用cmap="hot"看热力图。
6.2 tensor类型不匹配:半精度和整数类型不能被imshow直接处理
PyTorch的数据默认是float32,转numpy后直接imshow没问题。但如果你的训练流程里用了混合精度,把数据转成了float16,或者数据本身就是整型uint8,imshow的表现会不尽如人意。float16图像可能显示成一片灰白,uint8的话数值范围0-255会导致显示过曝。
稳妥的做法是在显示前统一转换:
# 统一转为float32的0-1范围 img_np = img_tensor.numpy() if img_np.dtype != np.float32: img_np = img_np.astype(np.float32) / 255.0这里按常见实践补充一句:如果你自己写了Dataset,返回的图像有时是PIL或numpy,类型可能五花八门,在imshow前统一格式永远是最省心的策略。
6.3 子图数量不等于图像数量:网格的“空洞”处理
16张图拼成4x4是完美的,但17张、18张、19张呢?这时网格尾部的若干子图没有数据填充,如果不做处理,空白子图会显示坐标轴边框,非常难看。前面代码里用if idx < num_images判断并ax.axis("off"),就是为了处理这种空洞。
更进阶的做法是在空洞区域显示一个提示文字或直接隐藏:
if idx >= num_images: ax.set_visible(False)set_visible(False)会让整个axes对象(包括边框和背景)彻底隐藏,版面比axis("off")还干净。
6.4 显示尺寸与保存尺寸对不上:figsize、dpi和实际像素的关系
很多人困惑一个问题:设置figsize=(8, 8),保存时dpi=150,最后图片像素是1200x1200,这个我是怎么算出来的?其实像素尺寸 = figsize英寸 × dpi,所以figsize=(8,8), dpi=150就是1200x1200。想在屏幕上精确控制显示大小,需要同时调整figsize和dpi。
如果我做报告或者嵌入网页,通常用figsize=(6, 6)加dpi=150;如果只是屏幕快速查看,figsize=(4, 4)不加dpi就够。拼接图像的画面本身信息密度不高,盲目把figsize调大只会让图像之间间距松动,整体反而不好看。
7. 从拼接单图到批量生成对比图:扩展思路和实用技巧
学会了基础的拼接,再往上走一步就是我们工作中真正高频的场景——多图对比。这里简单聊聊我是怎么在这个基础上扩展的。
7.1 模型预测结果和真实标签的对比视图
训练完一个分类模型后,我最常做的事是把一个batch的输入图像、真实标签、预测标签、置信度四个信息拼成一张复合图。做法其实就是在subplots网格里动态生成标题内容:
ax.set_title(f"True: {true_label}\nPred: {pred_label} ({confidence:.2f})", fontsize=9)标题字体颜色还可以根据预测对错来切换,ax.title.set_color("green")表示正确,red表示错误。这样一张图扫下来,模型的错误模式一眼就能定位——比如发现“Shirt”和“T-shirt/top”经常混淆,就可以针对性地加强这两个类的样本。
7.2 结果保存为多页PDF或直接嵌入训练日志
除了输出png,matplotlib还支持把多个figure保存到同一个PDF文件里,便于按轮次翻看训练过程中的可视化结果:
from matplotlib.backends.backend_pdf import PdfPages with PdfPages("training_samples.pdf") as pdf: for epoch in range(5): # 生成当前epoch的拼接图 fig, axes = make_grid_figure(...) pdf.savefig(fig)这个用法在代码评审和实验汇报时非常方便,不用维护一堆png文件。
7.3 gridspec进阶:让拼接图里的每张子图拥有自己的尺寸比例
如果你觉得subplots不够灵活,想在某一行放置尺寸比例不同的子图,可以用plt.GridSpec自定义布局矩阵:
fig = plt.figure(figsize=(10, 8)) gs = fig.add_gridspec(2, 3, width_ratios=[1, 1, 2], height_ratios=[1, 2]) ax1 = fig.add_subplot(gs[0, :]) ax2 = fig.add_subplot(gs[1, 0]) ax3 = fig.add_subplot(gs[1, 1]) ax4 = fig.add_subplot(gs[1, 2])当基础拼接逻辑已经熟练之后,这个进阶方向值得投入时间,因为实际项目中总是会出现“这一排要并排对比两张图,下一排要放一张大图”这类复杂布局。
8. 最后聊点实际体验
做可视化多年,最大的感受是:代码本身并不复杂,真正麻烦的是“数据格式不对、参数忘了写、边界情况没考虑”这类重复性的小摩擦。拼接图像这件事,核心就是三句话——数据先变成numpy且形状正确,用subplots创建网格并批量填充,最后别忘cmap和坐标轴开关。把这三句刻在脑子里,任何数据集、任何拼接需求都能快速套用。我个人建议把文中show_batch那个函数存成一个工具文件,以后接任何数据集都能直接调,能省下大量重复性调试时间。