图像拼接这个需求,做深度学习炼丹的应该都不陌生。不管是训练前的数据探索、训练中的过程监控,还是最后结果的展示汇报,总会遇到一个问题:怎么把一大堆图像按网格拼成一张图,直观地展示给别人看。很多人一开始都是循环里一张张用plt.subplot画,画完了发现代码里全是231、232这种魔法数字,想改布局就得从头改。后来换了plt.subplots,又搞不清楚返回的那个fig, axes到底是什么结构,稍微复杂点的场景就各种报错。
这篇文章就专门解决这个问题。我会以PyTorch + FashionMNIST数据集为例,从需求场景讲到代码实现,再到subplot和subplots的核心区别,最后给出一个可以直接套用的批量可视化框架。不管你是刚接触深度学习的新手,还是写了很多训练脚本但一直对matplotlib一知半解的老哥,这篇都能帮你在几分钟内拼出干净、专业的图像网格。
1. 先说结论:为什么图像拼接值得单独写一篇
1.1 这个需求从哪来
做图像分类、目标检测、生成模型等方向时,几乎每一步都绕不开“看图”。第一次接触一个新数据集,你得看它长什么样、标注对不对;训练过程中要时不时检查一下模型的输出,比如GAN生成的图片有没有崩坏;最后写报告或者做演示,又需要挑几张有代表性的结果放在一起对比。在这些场景下,把多张图“拼”成一张图是最高效的展示方式——一张图里能瞧见足够多的信息,还方便保存和分享。
有人可能会说,这有什么难的,不就是循环里画几个子图吗?但实际写起来就会发现几个痛点:
- 用
plt.subplot时,每次调用都要指定位置编号,比如plt.subplot(2, 3, 1),然后plt.subplot(2, 3, 2)。这种写法在图少的时候还行,一旦图一多,位置编号全是硬编码的魔法数字,改起来非常痛苦,而且代码看起来像绕口令。 - 图片显示时经常出现“一团黑”“全是伪彩色”的诡异现象,其实往往不是数据有问题,而是
imshow的参数没有配合数据维度设置好。 - 想调整图片间隔、外边距、每张图的标题字体大小、去掉坐标轴刻度,这些细节如果不清楚背后的机制,就只能对着官方示例一点点试,效率极低。
- 还有更进阶的需求:想在每张图下方标注“真实标签”和“预测标签”,想在训练循环里每隔几百步自动保存一张拼接图,想拼几十上百张图却不爆内存……
这些需求都指向同一个基础能力:用matplotlib的subplots或subplot去组合图像,并且把它的调用逻辑彻底搞清楚。这篇会从API区别到实际代码一次性把这件事讲明白。
1.2 为什么拿 FashionMNIST 做例子
选FashionMNIST不是因为它复杂,恰恰是因为它简单且典型。这是一个公开的服装图像数据集,包含10个类别,比如T恤、裤子、套头衫、裙子、外套、凉鞋、衬衫、运动鞋、包、短靴等,每张图是28x28像素的单通道灰度图。这类数据有三大好处:
第一,图像尺寸很小,加载和处理都很快,教程代码在普通笔记本上就能跑,不需要GPU就能复现。第二,灰度图省去了通道数的干扰,处理时不用考虑RGB三通道顺序问题,对新手更友好,但同时又能锻炼“单通道图如何正确显示”这个关键认知。第三,它本身就是分类任务的标准入门数据集,很多人在上面调模型、做实验,自然会产生可视化需求。用它做拼接演示,所有代码可以直接迁移到自己的真实项目里,不需要做任何数据格式上的适配。
另外也提前说一下,这篇文章里的代码风格偏工程化,我给每个关键步骤都配了参数说明和为什么这么写的解释,不是那种“复制能跑但一问就懵”的教程。下面先剖析subplot和subplots,这是整篇的地基。
2. subplot 和 subplots 到底差在哪
2.1 最直观的区别:调用方式和返回值
很多初学者会在同一个代码里混用subplot和subplots,然后完全摸不着头脑。其实从名字上就能看出大概:subplot是单数,一次创建一个子图;subplots是复数,一次创建整批子图。
先看subplot的经典用法:
import matplotlib.pyplot as plt plt.figure(figsize=(10, 5)) plt.subplot(1, 3, 1) plt.imshow(img1) plt.subplot(1, 3, 2) plt.imshow(img2) plt.subplot(1, 3, 3) plt.imshow(img3) plt.show()这里的plt.subplot(1, 3, 1)意思是把画布分成1行3列,然后激活第1个子图,之后所有绘图命令(比如imshow、title)都作用于这个子图,直到下一次调用subplot切换。这个API的好处是平铺直叙,正对着“当前活动子图”的概念,适合一步步顺序画图;坏处是三个数字要手动管理,一旦行列数变了,所有位置参数都要跟着改。
再看subplots的核心用法:
fig, axes = plt.subplots(2, 5, figsize=(12, 6))这一行就创建了一个2行5列的子图网格,fig是整个Figure对象,axes是一个形状为(2, 5)的numpy数组,里面装着10个坐标轴对象。之后你可以通过下标访问任意一个子图:
axes[0, 0].imshow(img1) axes[1, 4].imshow(img25)也可以先展平再遍历:
for ax, img in zip(axes.flatten(), images): ax.imshow(img)这就是两者最大的差别:subplot每次返回单个坐标轴,适合“按需激活当前画布”;subplots一次返回整组坐标轴对象,适合“批量规划然后统一填充”。在图像拼接场景中,绝大多数情况下subplots是更优解,因为它把所有子图作为数据结构暴露给你,而不是散落在全局状态里,后续修改和批量操作都方便得多。
2.2 背后的机制:Figure、Axes 与网格
要真正理解两者的区别,不能只看API表面,还得理解matplotlib的层次结构。matplotlib里最核心的两个对象是Figure和Axes。
Figure可以理解成“整张画布”,所有元素都画在它上面。它的尺寸由figsize控制,背景色由facecolor控制,保存图片时的分辨率由dpi控制。Axes则是画布上的“绘图区域”,每个图例、坐标轴、图像都隶属于某一个Axes。一张Figure上可以有很多个Axes,而subplot和subplots本质都是在做一件事:在Figure上划分出多个Axes,并把它们按网格摆放。
在底层,matplotlib会创建一个GridSpec网格对象来管理子图的位置和大小。plt.subplot(2, 3, 1)其实是在底层构造一个2行3列的GridSpec,然后取第1个位置的Axes并让它成为“当前坐标轴”。plt.subplots(2, 3)则一次性创建整个GridSpec,并返回所有位置的Axes组成的数组。
这个区别带来一个非常实际的影响:subplot模式下,你如果想在画完后统一调整子图的间距,只能用plt.subplots_adjust这样的全局配置;而subplots模式下,可以直接对axes数组里的每个对象单独设置属性,比如单独隐藏某个子图的坐标轴、单独改某个子图的标题颜色,操作粒度精细很多。
还有一个容易忽略的特性:subplots支持sharex和sharey参数。比如多张图如果都有同样的x轴范围,你可以设置sharex=True,这样所有子图共享x轴刻度,图像区域更紧凑,视觉上更整齐。subplot要实现同样的效果就得靠plt.setp遍历坐标轴,代码量明显更多。
2.3 什么时候用 subplot,什么时候用 subplots
既然subplots看起来碾压,是不是subplot就该丢进垃圾桶?也不至于。二者的适用场景其实有清晰边界。
用subplot的场景通常是非规则的、动态的绘图需求。比如你在写一个仪表盘脚本,需要在同一个画布上穿插绘制折线图、散点图、柱状图,每个图的类型都不同,由于图的数量少、位置不规律,直接用plt.subplot(2, 2, 1)一步步激活反而更直白。又比如你写的代码是函数式的,习惯“画一个图,再画一个图”这样线性推进,不想管理axes数组,那subplot的全局状态模式更顺手。
用subplots的场景则是批量网格。最典型的就是图像拼接:已经拿到了一个图像列表,想在网格里统一展示;或者要在每个子图上做同样的事情,比如都加标题、都关闭坐标轴、都设置相同的cmap。这时候subplots一次性生成所有子图,然后用循环统一处理,代码量少、可读性强、不容易出错。
一句话总结:单张动态绘制用subplot,批量统一网格用subplots。本文的所有拼接逻辑,都会基于subplots来实现。
3. 完整代码:加载 FashionMNIST 并拼接成一张图
3.1 环境准备和依赖
先搭环境。本文代码基于以下版本组合,Python 3.8+基本都能兼容:
- Python 3.9 或 3.10
- PyTorch 2.x(1.x也能跑,API差异不大)
- torchvision 0.15 以上(理论上0.10以上就能跑,但新版对download和transform的行为更稳定)
- matplotlib 3.6 以上
- numpy 1.20 以上
安装可以直接用pip:
pip install torch torchvision matplotlib numpy在开始写代码前,建议先在代码开头设置几个matplotlib的全局配置,尤其是中文字体和图像清晰度:
import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['SimHei', 'DejaVu Sans'] plt.rcParams['axes.unicode_minus'] = False为什么要单独提这个?因为很多人在后面画标题时会遇到中文乱码问题。如果不提前设置字体,plt.title里的中文会变成一个个方框,非常头疼。axes.unicode_minus是为了解决坐标轴上负号显示成乱码的问题,虽然拼接图像时可能用不到负号,但写上能防止其他绘图场景踩坑。
另外一个环境相关的建议是:如果你在无图形界面的服务器上跑脚本,直接调用plt.show()不会弹出任何窗口,程序会默默结束。这种情况下,请使用matplotlib.use('Agg')或直接不调用show(),只用savefig保存图片。关于这点后面展开讲。
3.2 加载 FashionMNIST 数据
数据加载用torchvision.datasets.FashionMNIST,这是最标准的入口。完整代码如下:
import torch from torchvision import datasets, transforms # 数据预处理:转Tensor transform = transforms.Compose([ transforms.ToTensor(), ]) # 训练集,download=True 时会自动下载到指定目录 train_dataset = datasets.FashionMNIST( root='./data', train=True, transform=transform, download=True ) # 类别名称 class_names = train_dataset.classes print(class_names) # ['T-shirt/top', 'Trouser', 'Pullover', 'Dress', 'Coat', # 'Sandal', 'Shirt', 'Sneaker', 'Bag', 'Ankle boot'] # 构建DataLoader,batch_size设为10便于展示10个类别 train_loader = torch.utils.data.DataLoader( train_dataset, batch_size=10, shuffle=True, num_workers=2 )这里有几个细节值得说明:
download=True会在指定的root目录下自动下载数据集。如果网络环境不稳定,下载多次失败,可以直接去数据集官网手动下载四个文件,放进./data/FashionMNIST/raw目录,再设置download=False。每个文件的字节数都是固定的,用哈希校验一下更稳。
ToTensor()会把PIL图像转成张量,像素值从0到255归一化到0到1,并且通道维度会排在最前面,所以每张图的shape是(1, 28, 28),这是后文所有处理逻辑的基础。如果调试时发现图像显示不对,第一个要检查的就是张量维度顺序。
DataLoader的batch_size设置成10,正好对应FashionMNIST的10个类别,方便我们后续每类取一张、拼成一行。shuffle=True保证每次获取的数据是随机打乱的,这样多次执行能看到不同的拼接效果。
3.3 核心拼接代码(含逐行说明)
数据准备好了,现在进入核心。下面这段代码从Loader里取一个batch,然后用plt.subplots把10张图拼成一行,并给每张图标注类别名称。
import matplotlib.pyplot as plt # 取一个batch数据 images, labels = next(iter(train_loader)) # images.shape: (10, 1, 28, 28) # 创建一个包含10个子图的Figure,一行10列 fig, axes = plt.subplots(1, 10, figsize=(12, 3)) for idx, ax in enumerate(axes): # 去掉batch维和通道维,得到 (28, 28) img = images[idx].squeeze(0) # 显示灰度图 ax.imshow(img.numpy(), cmap='gray') # 设置子图标题为类别名 ax.set_title(class_names[labels[idx]], fontsize=10) # 隐藏坐标轴 ax.axis('off') # 调整子图间距 plt.tight_layout() # 保存图片 plt.savefig('fashion_mnist_grid.png', dpi=150, bbox_inches='tight') # 可选:显示窗口 # plt.show()这段代码只有十几行,但每一行都值得展开讲。一定要理解的不是“能跑”,而是“为什么这么写能拼出干净整齐的图”。
先看plt.subplots(1, 10, figsize=(12, 3)):这里第一个参数是行数,第二个参数是列数。由于只分成一行,figsize的高度可以设小一点,否则画面会显得很空。figsize的单位是英寸,dpi默认通常是100,所以12x3的图实际输出尺寸约为1200x300像素,比例上适合横排长条子图。
for idx, ax in enumerate(axes)能直接遍历,是因为axes本身就是numpy数组。一行10列时它的形状是(10,),可以直接遍历;但如果是多行多列,比如2行5列,axes的形状是(2, 5),直接遍历会按行迭代,得到两个包含5个坐标轴的数组,而不是单个坐标轴。所以多行多列时一般先调用axes.flatten()或者axes.ravel()展平成一维数组。这两种方法几乎等价,ravel返回视图更快,flatten返回副本更保险,但在此场景下差异不大。
img = images[idx].squeeze(0)这一步的作用是去掉通道维。因为images[idx]的形状是(1, 28, 28),squeeze(0)会把它变成(28, 28)。对于一个28x28的二维数组,imshow会把它当作单通道图像,配合cmap='gray'就能正确显示灰度图。如果你忘了squeeze,把(1, 28, 28)的三维数组直接传给imshow,matplotlib会把它当作一张三个通道的彩色图——更糟糕的是每个通道都只有28x28的分辨率,并且实际通道的含义是完全错误的,最终显示出来的图往往是一团奇怪的伪彩色噪点。
ax.axis('off')是把坐标轴刻度全部隐藏。图像拼接场景下,坐标刻度和轴线都是噪音,不关掉的话每张图旁边都会出现一堆密密麻麻的数字,非常难看。如果想保留边框做一定的视觉分隔,也可以用ax.set_xticks([])和ax.set_yticks([])单独隐藏刻度而保留边框,二选一全看个人审美。
plt.tight_layout()会自动调整子图之间的空白间距,让标题不被截断、图像不互相挤压。这是一个很省事的函数,虽然它不是十全十美(复杂布局下偶尔会调出奇怪结果),但99%的场景都比手动调subplots_adjust方便。对于更精细的控制,可以用plt.subplots_adjust(wspace=0.1, hspace=0.2)手动指定子图之间的水平间距和垂直间距,wspace是宽间距比例,hspace是高间距比例。
plt.savefig('fashion_mnist_grid.png', dpi=150, bbox_inches='tight')把拼接结果保存到文件。这里有两个参数务必记住:dpi控制输出分辨率,数值越大图片越清晰,但同时文件体积也会变大;bbox_inches='tight'会裁掉图片周围的空白边,让结果看起来更紧凑。如果你在屏幕上看到的图和保存下来的图不一样,多半就是bbox_inches在起作用。
3.4 控制布局和出图的几个关键参数
人跟人审美不一样,任务跟任务要求也不一样。拼接图能不能直接用,很大程度上取决于布局参数调没调对。下面是几个最常调、也最值得理解的参数。
figsize:画布多大,子图就多疏密。当子图数量固定时,figsize越大,每张子图的显示区域越大,图像本身被拉伸的程度也越大。要注意的是这个拉伸是等比还是不等比,取决于你在imshow里有没有设置aspect参数。默认情况下,imshow会尽量保持图像的宽高比,这会导致一个问题:如果画布很宽很高、而每张图本身是正方形,那子图区域会出现大量的白边,图像没有填满整个子图区域。此时可以设置ax.imshow(img, cmap='gray', aspect='auto')强制图像拉伸填满子图。不过对于FashionMNIST这类图像本身长宽相等的场景,aspect='auto'反而会破坏原始比例,建议不要为了“看起来大”而牺牲图像的真实形状。
cmap:灰度图该配什么色。这个太重要了。cmap='gray'表示用黑到白的渐变色去映射数据。如果不指定,matplotlib对单通道图像默认使用的是viridis色彩映射,也就是从紫色到黄绿色的那种渐变,视觉上会非常出戏。始终记住一件事:只要是单通道数据,就显式指定cmap='gray',别指望默认行为能帮你。
squeeze:subplots返回值的形态。在plt.subplots内部有一个squeeze参数,默认是True。当子图网格中的行或列有一个是1时,比如(1, 10),返回的axes会被降维成一维数组(10,);如果行列都大于1,比如(2, 5),返回二维数组。如果设置squeeze=False,那么即使只有一行,也始终返回形状为(1, 10)的二维数组。大多数时候保持默认即可,但如果你写了一个通用可视化函数,输入的行列数不固定,建议显式设置squeeze=False,然后固定的用axes[0, idx]去索引,这样代码更统一,不容易出现“有时候能索引有时候报错”的玄学问题。
title和labels:标题信息不要瞎堆。在子图里加标题时,fontsize控制了标题的相对大小。如果整张图不大,标题字号比图像尺寸还大,观感很差。另外要注意set_title是加在坐标轴对象上的,plt.title是加在当前活动坐标轴上的,混用容易出错。尤其在使用subplots后,plt.title只会作用在“最后一个被激活”的子图上,而不是整张Figure。想要为整个Figure添加大标题,应该用fig.suptitle。
4. 真机实操:常见坑与排查思路
4.1 图像全黑或全绿,别急着怀疑数据
拼接图里出现全黑的图像,第一反应不应该是“这个batch数据是不是坏了”,而要先检查两步。第一步,检查图像张量的数值范围。ToTensor()之后,图像数据归一化到0到1,如果显示前画了某些操作把数据范围改成了0到255甚至-1到1,没有设置vmin和vmax的话,imshow默认会把数据的最小值和最大值映射为最暗和最亮,导致中间大部分灰阶显示不出来,看着就像全黑。第二步,检查通道维度。前面说的(1, 28, 28)不squeeze就显示,是产生绿色或者彩虹伪影的高频原因。这两个坑并排写,是因为它们表面上都表现为“图像显示不正常”,但排查思路完全不一样:前一个查数值范围,后一个查维度顺序。
遇到过这种情况的可以做一个快速自检:在代码里加上一行print(images[idx].shape, images[idx].min(), images[idx].max()),输出维度、最小值、最大值,这几个数一眼就能看出是哪个环节出了问题。调试可视化的第一原则就是先把自己的数据“打印出来看清楚”,不要对着黑屏瞎猜。
4.2 中文标签变成方框
很多中文环境下的matplotlib初学者都栽过这个跟头。class_names里面如果用了中文翻译后的类名,比如“套头衫”“运动鞋”,不设置中文字体的话,set_title绘制出来的就是一排方框。因为matplotlib的默认字体不包含中文字符集,渲染时找不到对应字形,只能用占位符代替。
解决办法是在代码开头设置:
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei', 'PingFang SC'] plt.rcParams['axes.unicode_minus'] = False字体列表从左到右按优先级排列,系统里装了哪个就用哪个。同时建议把axes.unicode_minus也设成False,否则坐标轴上的负号可能显示成方块。如果设了字体代码里中文还是乱码,大概率是字体名字拼错了,或者系统根本没装对应字体。Linux服务器可以通过fc-list :lang=zh命令查看已安装的中文字体,缺什么装什么。
如果你不想被中文字体问题纠缠,还有一个更稳但不太推荐的办法:直接使用数据集的英文类名,不翻译。FashionMNIST自带的class_names本身就是英文,比如T-shirt/top、Trouser,保存出的图在任何机器上都一样,不用额外配置字体。实际项目里如果只给自己看,中文确实更方便;但要发到论坛、写进论文,还是建议用英文,省心。
4.3 维度不匹配导致的隐性报错
在使用PyTorch加载数据时,一个batch的维度通常是(batch_size, channels, height, width),也就是(10, 1, 28, 28)。但如果你用OpenCV读取图像,读取出来的维度是(height, width, channels),也就是(28, 28, 1)。两种数据直接喂给同一个显示函数,imshow的解析结果完全不同:前者会被当作用0到1浮点数表示的多通道图,后者会被当作用0到255整数表示的单通道图。很多时候报错不一定马上出现,只会表现为“图怎么是反的”“颜色怎么不对”。
这里给一个统一的建议:写一个专门的show_image函数,进去以后强制把numpy数组整理成(H, W)或者(H, W, 3)的标准格式,然后再调imshow。比如:
def show_image(ax, img_np): if img_np.ndim == 3 and img_np.shape[0] == 1: img_np = img_np.squeeze(0) ax.imshow(img_np, cmap='gray' if img_np.ndim == 2 else None) ax.axis('off')这样无论传入的数据是从PyTorch张量转来的(1, 28, 28),还是OpenCV读出来的(28, 28, 1),都能得到视觉上正确的显示。把琐碎的维度判断封装起来,你的拼接代码在多个项目里都不用改。
4.4 布局重叠与图片被截断
用subplots创建网格时,如果子图数量很多,比如10行10列,默认间距下标题和图像会互相挤在一起,甚至部分图像被画布边缘截断。此时首要手段是调figsize,把它设到足够大,比如figsize=(15, 15)。其次用plt.tight_layout()做强制的自动排版。还不行的话,就用plt.subplots_adjust(left=0.05, right=0.95, top=0.95, bottom=0.05, wspace=0.15, hspace=0.2)手动调整边距和各子图间的间距。这些参数在调的时候不要凭感觉乱来,记住一个规律:left/right是整张图左右两侧的空白边界,top/bottom是上下边界,wspace和hspace是子图之间的间隔比例,值越大空得越开。从极端值往回收着试,很快就能找到合适的组合。
还有一种常见情况:保存出来的图,左右两侧被裁掉一部分。这通常是因为bbox_inches='tight'配合某些自动布局时,计算出的边界和实际内容不相匹配。解决办法是去掉bbox_inches='tight',改用固定边距,或者反过来,把bbox_inches保留但减少tight_layout的效果。两条路试一条,保存出来的图通常就会正常了。
5. 从拼接一张图到批量可视化框架
5.1 用 subplots 批量输出多批数据预览
拼接一张图只是入门。实际应用中,你往往需要批量地为很多个batch生成预览图。依然用FashionMNIST,假设我们要为前5个batch各生成一张拼接图,怎么做?
思路很简单:外层遍历batch,内层遍历当前batch种的每张图,每个batch生成一张图并保存。代码示例:
import os os.makedirs('preview', exist_ok=True) for batch_idx, (imgs, labels) in enumerate(train_loader): if batch_idx >= 5: break fig, axes = plt.subplots(1, 10, figsize=(12, 3)) for ax, img, label in zip(axes, imgs, labels): ax.imshow(img.squeeze(0).numpy(), cmap='gray') ax.set_title(class_names[label], fontsize=10) ax.axis('off') plt.tight_layout() plt.savefig(f'preview/batch_{batch_idx:02d}.png', dpi=120) plt.close(fig) # 关键:释放内存这里有个极其关键的细节:plt.close(fig)一定不能漏。很多人在循环里生成图片,内存占用以肉眼可见的速度飙升,最后程序卡死,就是因为matplotlib把所有Figure对象都保持在内存里没有释放。每循环一次创建一张图,不close的话,几十上百张图累积起来,内存很快就被吃光。这属于那种“程序跑很久才发现问题,排查半天才发现是漏了一行”的坑,提前养成习惯能省很多事。
如果你不想生成多张单独的图片,而是想在一张很大的画布里一次性展示所有batch的所有图片,思路完全一样,只是把plt.subplots的参数设为总规模。比如5个batch、每批10张,那就是5行10列,一遍循环把数据铺进去,一行对应一个batch。此时每个子图的尺寸会变小,记得调大figsize并考虑是否要给每行加一个“batch编号”的标签。
5.2 在拼接图上叠加标签和预测结果
用来展示训练效果的拼接图,往往不只是显示原始图像,而是要对比“真实标签”和“模型预测标签”。这在实际工程里太常用了。
实现思路是:先获取一个batch的输入,经过模型推理得到预测结果,然后在子图标题里同时放上真实标签和预测标签,并把预测错误的信息用颜色强调出来。示例代码:
model.eval() with torch.no_grad(): images, labels = next(iter(train_loader)) outputs = model(images) _, preds = torch.max(outputs, 1) fig, axes = plt.subplots(1, 10, figsize=(12, 3)) for idx, ax in enumerate(axes): ax.imshow(images[idx].squeeze(0).numpy(), cmap='gray') true_name = class_names[labels[idx]] pred_name = class_names[preds[idx]] color = 'green' if labels[idx] == preds[idx] else 'red' ax.set_title(f'真:{true_name}\n预:{pred_name}', fontsize=9, color=color) ax.axis('off') plt.tight_layout() plt.savefig('prediction_grid.png', dpi=150)这个示例能用,但更复杂的工程里建议把所有可视化逻辑封装成一个函数,比如visualize_batch(images, labels, preds, save_path, class_names)。封装之后,训练循环里只要一行调用,就能自动生成监控图片。做模型调参和bug排查时,这种“每跑一个epoch就自动保存预测图”的习惯非常有用,能让很多问题在苗头阶段就被发现。
torch.max(outputs, 1)返回两个值,分别是最大值和对应的索引,_用来忽略最大值部分,preds就是预测类别的索引。如果你习惯用outputs.argmax(dim=1),效果完全一样。一个小提醒:推理时记得在torch.no_grad()上下文里进行,否则模型计算图会被保留,显存消耗成倍增加。这个错误我在前期踩过很多次,分享出来省得大家再踩一遍。
5.3 扩展一:用 subplot_mosaic 做复杂布局
如果subplots的规则网格满足不了你,比如你想让一张图占两个子图的位置,或者第一行三张、第二行两张且位置不对称,这时候subplot_mosaic是一个极其好用的工具。它是matplotlib 3.3之后引入的新API,用类似ASCII艺术的字符串来定义布局:
fig, axes = plt.subplot_mosaic( [['big', 'small1'], ['big', 'small2']], figsize=(8, 6) ) axes['big'].imshow(large_image, cmap='gray') axes['small1'].imshow(small_img1, cmap='gray') axes['small2'].imshow(small_img2, cmap='gray')这种写法的最大优势是把布局意图用形状直接表达出来,比数字参数直观得多。等号、加减号等符号还能创建跨越多个行列的复杂网格。对于做结果对比、论文插图这类有定制布局需求的任务,建议重点掌握这个API。
5.4 扩展二:把可视化嵌入训练流程
最后提供一个很实用的工程技巧:把图像拼接可视化嵌入到训练循环中。常见的做法是定义一个plot_to_numpy函数,把matplotlib绘制的图像直接转成numpy数组,然后传给TensorBoard或Weights & Biases这类可视化工具记录。这样在训练日志里能实时看到每一轮的数据预览和模型输出,不需要打开本地图片文件。
def fig_to_numpy(fig): fig.canvas.draw() data = np.frombuffer(fig.canvas.tostring_rgb(), dtype=np.uint8) data = data.reshape(fig.canvas.get_width_height()[::-1] + (3,)) return data用法就是把之前所有绘图逻辑放到画布上,最后调用fig_to_numpy(fig)得到数组,再用writer.add_image('train_preview', img_array, global_step)写入TensorBoard。这个方案的好处是所有的日志集中在一个面板,训练过程中的任何变化都能随时回看。当然,要注意绘图本身有CPU开销,不要每个step都画,几百步画一次足够。
这些扩展功能不是必须的,但当你从“把图拼出来”升级到“让可视化服务于训练流程”时,它们能让整个项目的调试效率和展示效果提高一个档次。基础的部分只要吃透了,这些扩展就是举一反三的事。我在实操中最大的体会是:可视化代码一定要封装。封装成函数之后,训练脚本会变得干净整洁,可视化和模型逻辑完全解耦,demo阶段随手写的代码才能真正沉淀成自己的工具箱。建议你拿到这套拼接方法后,第一时间把它包装成自己项目里的通用函数,参数不用多,一两行就能调用的程度就够了。