资讯动态

PyTorch图像拼接实战:FashionMNIST可视化与subplots用法详解

发布时间:2026/10/15 20:44:19 来源:尧图企业网站定制
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, trainTrue, downloadTrue, transformtransforms.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)的三维tensormatplotlib的imshow接收不了这种带通道维的灰度图。2.2 CHW到HWC转换单通道图像用squeeze三通道必须用permutePyTorch的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, cmapgray, vmin0, vmax1)我用vmin/vmax这个参数的频率非常高尤其是在失手用错数据时它能救回来很多“看起来全黑”的情况。后面拼接统一输出时建议在imshow里显式写上vmin0, vmax1这样无论数据来源是什么显示效果都稳定。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支持更多参数比如squeezeFalse强制保持二维数组形状或者sharexTrue共享坐标轴刻度这些在subplot里都得绕路实现。但我不建议完全忽略subplot。它背后其实隐藏了matplotlib的坐标轴机制——每次subplot被调用都会在figure上新建一个axes区域。理解了这个机制你就能明白为什么subplot可以“一个接一个”地创建不同大小的子图也能理解subplots的gridspec参数是怎么回事。简单说subplot是更底层的逐个操作subplots是对常见需求的批量封装。对比维度plt.subplotplt.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, trainTrue, downloadTrue, transformtransforms.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], cmapgray, vmin0, vmax1) ax.set_title(labels[idx], fontsize10) 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, nrow4, padding2, normalizeTrue) # 转成numpy并调整维度 grid_img grid_tensor.permute(1, 2, 0).numpy() plt.imshow(grid_img) plt.axis(off) plt.show()make_grid的好处是不需要手动spawn网格和循环填充nrow参数直接控制每行几张。normalizeTrue会按整个网格的像素范围做归一化显示效果往往比自己拼的更好看。但这个方法有两个局限一是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], fontsize10)大家都会写但有几个tips如果标题太长比如“T-shirt/top”可以加rotation0并调小fontsize否则会和图像重叠。想显示预测值和真实值两个信息时可以在同一个标题里用两行ax.set_title(f{true_label}\npred: {pred_label}, fontsize9)。批量设置时注意标题的字体大小和图像尺寸的比例28x28的图像本身很小标题过大反而喧宾夺主。5.3 tight_layout、subplots_adjust与保存分辨率拼接完成后plt.tight_layout()几乎是必须的它会自动调整子图之间的间距防止标题重叠或坐标轴被截断。如果tight_layout之后还嫌间距太大或者太小就得手动手动调整plt.subplots_adjust(wspace0.05, hspace0.05)把子图之间的水平垂直间隙压到极小值这样拼出来的大图才有“无缝拼接”的紧凑感。保存到本地时我常用fig.savefig(fashion_mnist_grid.png, dpi150, bbox_inchestight)bbox_inchestight会把周围的留白裁掉按内容实际大小保存这个参数比手动调figsize更靠谱。dpi150在报告和网页展示时基本够用如果是要印刷或者发论文建议提到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_namesNone, cols4): 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, cmapgray, vmin0, vmax1) if class_names is not None: ax.set_title(class_names[labels[idx]], fontsize10) ax.axis(off) plt.tight_layout() return fig, axes这里有个细节如果batch size是1plt.subplots(1, 1)返回的axes是单个对象而不是数组直接axes.flatten()会报错所以加了个判断。这种边界条件最容易在复用代码时爆雷提前处理好能省很多事。6. 踩坑记录我在拼接FashionMNIST时碰到的三个典型问题这部分是我自己反复踩过的坑分享出来的价值往往比主流程代码更大。每一个都曾经让我调试到怀疑人生后来发现都是些很基础的细节。6.1 灰度图变成了“大花脸”忘记指定cmap第一次拼FashionMNIST的时候我把数据转换都做好了subplots也创建了imshow里没写cmap参数结果显示出来全是紫绿相间的伪彩色图。一开始我还以为是数据加载问题后来才反应过来matplotlib的imshow默认不指定cmap时对二维灰度数组用的是viridis彩色映射不是灰色。这个坑的教训是显示灰度图像时cmapgray几乎永远是必写参数。唯一可能例外的是你确实想用伪彩色增强对比比如用cmaphot看热力图。6.2 tensor类型不匹配半精度和整数类型不能被imshow直接处理PyTorch的数据默认是float32转numpy后直接imshow没问题。但如果你的训练流程里用了混合精度把数据转成了float16或者数据本身就是整型uint8imshow的表现会不尽如人意。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)保存时dpi150最后图片像素是1200x1200这个我是怎么算出来的其实像素尺寸 figsize英寸 × dpi所以figsize(8,8), dpi150就是1200x1200。想在屏幕上精确控制显示大小需要同时调整figsize和dpi。如果我做报告或者嵌入网页通常用figsize(6, 6)加dpi150如果只是屏幕快速查看figsize(4, 4)不加dpi就够。拼接图像的画面本身信息密度不高盲目把figsize调大只会让图像之间间距松动整体反而不好看。7. 从拼接单图到批量生成对比图扩展思路和实用技巧学会了基础的拼接再往上走一步就是我们工作中真正高频的场景——多图对比。这里简单聊聊我是怎么在这个基础上扩展的。7.1 模型预测结果和真实标签的对比视图训练完一个分类模型后我最常做的事是把一个batch的输入图像、真实标签、预测标签、置信度四个信息拼成一张复合图。做法其实就是在subplots网格里动态生成标题内容ax.set_title(fTrue: {true_label}\nPred: {pred_label} ({confidence:.2f}), fontsize9)标题字体颜色还可以根据预测对错来切换ax.title.set_color(green)表示正确red表示错误。这样一张图扫下来模型的错误模式一眼就能定位——比如发现“Shirt”和“T-shirt/top”经常混淆就可以针对性地加强这两个类的样本。7.2 结果保存为多页PDF或直接嵌入训练日志除了输出pngmatplotlib还支持把多个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那个函数存成一个工具文件以后接任何数据集都能直接调能省下大量重复性调试时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑