ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Tensor转PSD:深度学习特征图可视化与模型调试的高效工具

Tensor转PSD:深度学习特征图可视化与模型调试的高效工具

最近在调试一个图像生成模型的时候,我最大的一个感受是:训练指标只能告诉你“模型有没有收敛”,但很难告诉你“模型到底看到了什么”。尤其是想把中间层的特征图翻出来仔细看,以前我都是直接保存成一张拼接大图,结果一放大全是锯齿,想看某一个通道的细节还得重新切图,效率非常低。后来我写了draw_tensor2psd_v4.py,把张量数据直接转换成 PSD 分层文件,输出目录命名的结果0129,是1月29号跑的一批中间层可视化结果。整套流程用下来,基本解决了“看特征图看得不清楚、不直观、不好整理”的问题。这篇文章就把这个工具的思路、实现细节和踩过的坑完整复盘一下,给同样被模型可视化折腾的朋友做个参考。

如果你还没接触过这类场景,我先用一句话解释清楚:所谓 Tensor 转 PSD,就是把模型中间层的张量,通常是(N, C, H, W)的形状,拆成单张通道图,然后按通道顺序堆叠成 Photoshop 的 PSD 图层文件。这样在 PS 里打开后,可以自由开关图层、调整混合模式、单独查看某一个通道的纹理,也能直接对比两个不同通道之间的差异。对于做视觉模型调试的人来说,这比拼接 PNG 实用太多。

下面我从这个项目的完整设计思路、核心实现细节、结果复盘和常见坑位四个角度,逐一展开。

1. 项目定位与核心需求拆解

很多人会说,可视化张量不是有现成工具吗?用 matplotlib 或者torchvision.utils.make_grid直接保存图片不就行了?但真正跑过实际训练的人都知道,这两者都有明显的短板。

matplotlib 适合画曲线图,画特征图很别扭,每张图还得单独imshow+savefig,如果 batch 大了、通道多了,内存和磁盘都是问题。make_grid则是把通道缩略图拼到一张图上,看上去很紧凑,但本质上是把C个通道压扁到一张小图里,每个通道可能只有几十像素,根本看不清楚细节。更麻烦的是,拼图时还需要手动指定nrow和padding,想要单独提取某张特征图做大图,得重新写代码。

所以draw_tensor2psd_v4.py在最初规划时,核心需求非常明确:能够把一个 batch 的中间层输出直接存成一组 PSD 文件,并且每个通道独立成层,图层名称直接带上通道索引和数值统计信息,方便在 PS 里快速定位和检查。

我总结下来,这个工具需要解决四个问题:

  1. 输入数据灵活:既能接收 PyTorch 的 tensor,也能接收 NumPy 数组,甚至能从.npy文件直接读。
  2. 通道分层清晰:每个通道单独一层,图层名包含layer_01、layer_02这样的索引,而不是一堆Layer 0、Layer 1的默认命名。
  3. 数值转换合理:模型特征图的数值范围不稳定,可能从负数到正数都有,需要做归一化同时保留极值信息。
  4. 批量输出可管理:一个 batch 可能有几十张图,每个样本一个 PSD,命名规则要能对上原始数据,比如sample_0001.psd。

V4 版本之前,我也写过几个临时脚本,但都停在小规模测试上,没有考虑过性能。直到某次实验中间层张量的形状是(32, 512, 32, 32),如果用老办法逐通道拼接,光处理一个 batch 就要好几分钟,而且生成的 PSD 文件动辄一两个 GB,打开都费劲。那时候才意识到,这个工具需要做一次彻底重构,于是有了draw_tensor2psd_v4.py。

这个脚本的适用人群,我建议只要是做视觉相关模型训练、调试、或者分析生成模型中间表征的工程师和研究者,都非常合适。哪怕你只是想把某个卷积层的输出做一次深入的细看,它也比直接matplotlib保存强得多。

2. draw_tensor2psd_v4.py 的设计思路与迭代逻辑

V4 这个版本不是一蹴而就的,我对它的心智模型是:把“数据处理”和“文件生成”两个阶段彻底解耦。数据处理只关心张量如何归一化、通道如何切分、统计信息如何计算;文件生成只关心如何把这些数据高效地写入 PSD 格式。两层分离之后,后续不管是从 PyTorch 读数据还是从磁盘读.npy,都不需要改动 PSD 生成逻辑。

2.1 前三版为什么不行

我在 V1 阶段用的是纯pytoshop手搓 PSD 图层,文件结构虽然正确,但每次只能处理单张图,而且图层通道顺序很反直觉。V2 版本加了 batch 循环,但生成速度依旧慢,原因是它每写一层都要重新构造一次PSDWriter类的内部字典,内存占用极高。V3 版本开始做通道命名和统计信息,发现 Photoshop 对图层名称中的特殊字符很敏感,比如/、\会被自动过滤,导致保存后图层名和预期不符。

V4 做的核心改变有三点:

  • 将张量数据预先转成 uint8 的像素数组,释放掉原始 tensor 的对象引用,减少内存压力。
  • 采用流式写入图层,不再是“构造完所有图层后再统一写入”,而是边归一化边写入临时文件,最后再合并成 PSD。
  • 加入错误隔离机制,单个 batch 内某一个样本解析失败,不会让整个任务中断,而是记录失败日志继续跑。

2.2 PSD 格式的选型与原理

PSD 文件本质上是一个带压缩的位图容器,它可以包含多个图层、通道、蒙版和路径。与 PNG、JPEG 最大的区别是,它保留了完整的图层信息和通道独立性,所以非常适合用来做特征图的分层展示。

Photoshop 在读取 PSD 时,会把每个图层当成一个独立的文档层。这意味着我们在 Python 端生成 PSD 时,必须遵循它的图层块结构:文件头、图层信息段、图层图像数据段。如果某个字段颜色模式不对,PS 打开时就会报“无法完成请求,因为文件格式模块不能解析该文件”。

V4 选择了 PSD 的 RGB/8 色彩模式,而不是灰度模式。原因是灰度模式虽然文件更小,但无法在图层上附加颜色映射,后续如果你想给某一层套个伪彩查看热力图,灰度模式就非常不方便。RGB 模式下,每个通道我们仍然用灰度值填充,也就是 R=G=B=像素值,这样既兼容了 PS 的伪彩功能,也不会丢失灰度细节。

2.3 文件命名与结果目录结构设计

draw_tensor2psd_v4.py的输出目录结构,我是按“结果名/批次/单样本”三层来组织的。比如这次的结果0129,目录下面长这样:

结果0129/ 0001/ step-0001_layer-001_conv1.psd step-0001_layer-002_conv1.psd step-0001_layer-003_relu.psd 0002/ step-0002_layer-001_conv1.psd ...

文件名的规则是step-{迭代步数}_layer-{通道序号}_{层名}.psd。这样设计的好处是,当你把几十个文件拖进 PS 批处理时,排序规则直接按关键字段排列,不会出现同层名的文件互相覆盖。另一个容易忽略的细节是,我把“通道序号”补成了三位数,之前从两位数改成三位数是因为通道数超过 99 之后,layer-100会排在layer-20前面,拖进 PS 时顺序会乱。

3. 核心实现细节:张量读取、归一化与图层顺序

这部分是整篇文章最硬核的地方,我直接贴核心代码片段,并解释每个关键参数为什么这么设。

3.1 输入数据的加载与预处理

脚本接受三种输入方式:PyTorch Tensor、NumPy 数组、文件路径。如果传的是路径,脚本会优先读取.npy文件,避免每次重新从网络模型里导特征图。

import numpy as np import os def load_tensor(data, device="cpu"): if isinstance(data, str): arr = np.load(data) return torch.from_numpy(arr).to(device) if isinstance(data, torch.Tensor): return data.detach().to(device) if isinstance(data, np.ndarray): return torch.from_numpy(data).to(device) raise TypeError("unsupported data type")

这里有一个细节:detach()非常重要。如果是 PyTorch 的叶子张量,可能还带着梯度图,不 detach 的话后续转换 numpy 会报错“Can't call numpy() on Tensor that requires grad”。我最初在 V1 版本就吃过这个亏,每次都在主训练进程里顺带调用这个脚本,结果好好的,后来改成离线分析就炸了。

3.2 通道归一化的边界处理

特征图数值范围不是固定的,比如某个卷积层输出分布在[-2.5, 8.0],而另一个 BN 层的输出可能是[0.0, 0.9]。如果直接min-max归一化,会导致某些层的对比度被极值拉伸得特别夸张,肉眼什么都看不出。

所以 V4 里我加了一个clip_percentile参数,默认设为 1.0,也就是说把数值分布的两个端点 1% 的异常值先截掉,再做线性拉伸。原理与图像处理中的直方图裁剪类似,截断之后,即使某一层存在少数极端大值,中间的纹理细节也不会被压成一片黑或一片白。

def normalize_channel(data, clip_percentile=1.0): low, high = np.percentile(data, [clip_percentile, 100 - clip_percentile]) if high - low < 1e-6: high = low + 1e-6 normed = (data - low) / (high - low) return np.clip(normed, 0.0, 1.0)

其中high - low < 1e-6的防御性判断,是针对某些通道输出的数值完全相同的极端情况。如果不加这个判断,除零会直接把整块内存变成 NaN,然后写入 PSD 时 PS 会提示文件损坏。

3.3 将通道写入 PSD 图层

PSD 图层写入方式,我用的是pytoshop库的底层接口。这个库比起另一个常用的psd-tools更侧重写入,psd-tools更适合解析已有 PSD,写复杂图层结构时坑比较多。

核心的写入逻辑是两段:先确定文件头,再逐个图层写入。这里放一个简化版:

from pytoshop import user, nested def write_layers_to_psd(layers, output_path, width, height): root = nested.Node() root.name = "root" root.file = user.File() root.file.width = width root.file.height = height root.file.channel_count = 3 root.file.color_mode = 3 # RGB for name, gray_pixels in layers: layer = nested.Layer() layer.name = name layer.opacity = 255 layer.channels = { 0: gray_pixels, # R 1: gray_pixels, # G 2: gray_pixels, # B -1: b'\x00' * (width * height) # alpha,全不透 } root.layers.append(layer) root.file.write(output_path)

layer.opacity我用 255 表示完全不透明。如果某个通道你想做成半透明叠加,可以调这个值,但那是更高级的玩法了,默认为全透明反而更适合逐层检查。

图层顺序这里有个坑:PSD 中图层的顺序是“从上往下”,也就是说第一个写入的图层在最上面。而特征图的通道顺序一般是channel_0在最前面,如果你想让channel_0在 PS 的最上层,就直接按顺序写入即可。如果你想反过来,从channel_0在底部,就得 reverse 一下。

3.4 统计信息写入图层注释

纯灰度图层打开后,光看纹理很难判断这个通道的数值范围是多少。如果每种 feature map 都要手动看数值,那效率又回去。

我在 V4 里多加了一个环节:在图层名称里嵌一组统计信息,例如:

conv1_ch01 [min:-1.2 max:3.5 mean:0.21]

这样在 PS 里打开图层面板时,不用点“信息”面板,直接看名字就知道这个通道的激活特性。不过,图层名称太长了在 PS 里会被截断,所以统计信息只能保留三位小数,并且用了min/max/mean而不是完整历史分布。

4. 结果0129的复盘:这批输出怎么看

结果0129这个目录是我在1月29号跑完一次图像生成模型调试后留下的完整输出。当时模型刚改完损失函数,整体收敛趋势还行,但生成图像总是出现局部模糊现象。用传统的拼接图完全看不出问题,后面直接跑了draw_tensor2psd_v4.py,把生成网络基本模块的中间层全部导出成 PSD,才把问题定位清楚。

4.1 查看 PSD 的常规流程

拿到 PSD 后,打开 Photoshop,我建议先做三件事:

  • 把图层面板展开,按“通道序号”排序,这样你能一眼看出哪些通道数值异常。
  • 打开“信息”面板,用鼠标扫一遍灰度值,直观感受每层的像素分布。
  • 按住 Alt 键双击图层缩略图,切换到单独图层视图,这样不会被其他叠加干扰。

我一般会开一个窗口放整张图,另一个窗口锁定某个异常通道,两边对比着看。比如某次我注意到层名里的max值是 15.7,但整张图的灰度值全部低于 128,这说明绝大部分像素都集中在较低数值区域,高值区域可能只有极少数点,那这个通道传达的信息就可能是“稀疏特征检测器”。

4.2 通过 PSD 发现模型潜在问题

从结果0129里我发现了一个比较隐蔽的问题:有一组通道的min永远是 -4.2,max永远是 6.8,这三个参数在二十多张样本里几乎没变。正常来说,不同图片输入同一个卷积层,响应数值应该会有差异,如果每个样本的特征图都是同一个分布,大概率说明这个卷积层已经对输入不再敏感,发生了所谓的“梯度消失”或“死卷积”。

我单独抽出该通道对应图层,发现它的纹理确实像噪声,完全没有看到边缘、形状或者颜色斑块。这一发现直接推动了我调整网络的初始化策略和残差连接,而不是继续盲目训练十几个 epoch 再去分析。

4.3 数值统计辅助判断训练健康度

另外一个经验是,如果某个通道的图层名里mean持续在 0.5 附近波动,但std非常小,说明这个通道可能一直输出接近常数的值。这个现象在很多生成模型中比较普遍,常见于 GAN 的判别器后期,几乎每个通道都在输出一个偏向“真/假”的分量,而不是学到有语义的特征。

我后来的习惯是每个 epoch 结束跑一次draw_tensor2psd_v4.py,把所有中间层输出存到日期目录里。如果发现某一天的结果和前一天差异不大,那基本可以判断模型已经进入平台期。PSD 的额外出力在于,你随时可以回到某一天的某个通道,做局部的像素级对比。

5. 常见问题与避坑指南

这部分我整理一下实际使用过程中最常遇到的一堆问题,很多都是依赖库的边界情况或者格式规范问题,文档里根本不会写。

5.1 PSD 文件过大,打不开怎么办

特征图尺寸如果是112x112,512 个通道,每个通道单独一层,出来一个 PSD 很容易超过 2GB。Photoshop 对大文件支持虽然还可以,但打开速度慢、内存占用高。如果你只是想快速抽查,我建议两种方案:

  • 方法一:先抽取一部分通道生成一个精简版 PSD,比如把 512 个通道每隔 8 层取一层,出来 64 层,文件小很多。
  • 方法二:将所有通道的图层降采样到原图的 1/4 宽高,比如112x112变成28x28,这样文件体积基本变成原来的 1/16。

V4 里我加了一个downsample_factor参数,默认是 1,如果你明确知道某个阶段只需要看整体分布,可以设成 0.5 或 0.25,观测速度会大幅提升。

5.2 图层顺序与预期相反

这是一个很常见的坑。如果你把数据按channel_0到channel_N顺序写入 PSD,它们在图层面板里默认是channel_N在最上,channel_0在最下。原因是 PSD 的图层存储顺序是反的,先写的先出现在列表底层。解决方案是写入前将图层列表翻转:

layers = list(reversed(layers))

但要注意:如果你使用某些工具(比如psd-tools)读取这个文件,读取到的顺序又会反过来。所以最好在脚本里设一个常量PSD_LAYER_TOP_TO_BOTTOM,然后保持一致。

5.3 特殊字符导致图层名被吞

Photoshop 对图层名称中的字符支持虽然比 Windows 文件系统好,但/和\会被强制转换,:在某些中文版系统里也有显示问题。如果你用网络层名拼接文件层名,比如conv1/x,写入后图层名会变成conv1x。这让排查图层时非常困惑。

我现在的规范是:图层名字符集只保留A-Z、a-z、0-9、_和-,任何其他字符统一替换成_。函数就三行,但价值很大。

5.4 NaN 和 Inf 导致文件损坏

当张量里存在NaN或Inf时,normalize_channel的结果也会变成 NaN,强制转 uint8 时会变成 0,最后整个图层显示为纯黑。纯黑会误导判断,你以为这是“未激活”,实际是“数值异常”。

V4 里我加了一个safe_fill选项,默认将 NaN/Inf 直接填充为 255(纯白),同时在日志里打一个警告,列出具体是哪个通道出了问题。这样在 PSD 里一眼就能看到异常层,而不是默默黑掉。

if not np.all(np.isfinite(data)): logger.warning(f"channel {idx} contains NaN/Inf, filling with white") data = np.where(np.isfinite(data), data, 1.0)

5.5 PSD 版本兼容性

PSD 文件格式本身有一个版本字段,早期我用有些库写出来的 PSD 在 Photoshop 新版本里可以打开,但在旧版本 CS2 里打不开。如果你需要兼容旧版本,记得在文件头里使用color_mode=3而不是color_mode=1,并且把图层数控制在 200 层以内,旧版本对这个限制比较严格。

6. 从 V4 往后还可以怎么扩展

这个脚本目前满足了我日常调试的需求,但说实话还有不少提升空间。比如,PSD 中的图层混合模式有两种叠加方式:正常模式和线性减淡。如果你想模拟特征图的热力图叠加效果,可以在写入图层时把混合模式设置为线性减淡,这样多通道叠加看起来会更有层次感。不过线性减淡模式依赖颜色深度,部分老版本 PS 不支持。

另一个可以尝试的方向是把多个通道按相关性聚类分组,同一组放在同一个图层的组目录里,然后用 PS 的组文件夹统一开关。这部分需要额外计算通道间的相关性矩阵,我目前是用sklearn.cluster做 KMeans,再按聚类标签调整图层树。

再就是把每个通道的直方图以迷你条形图的形式,作为一个附加图层写入同一个 PSD,这样打开文件就能直接看到数值分布走势。我没把这个加到 V4 里,主要是当时时间不够,但设计思路是可行的。

作为长期维护的话,我建议把这个脚本整理成一个类接口,而不是现在的函数式风格,这样后续调用不同模型时会清爽很多。如果你有兴趣自己实现,可以从 V4 的代码基础上一步步加功能,不要重新造轮子。

最后分享一个我个人印象很深的小细节:真的不要小看“图层命名”这个点。最初我把所有层都叫Layer 0、Layer 1,打开 PS 根本不知道谁是谁,后来改成有信息的命名之后,整个分析效率直接翻倍。很多自动化工具的本质不是做多复杂的事,而是把重复劳动变得足够顺手,顺手到你会愿意每天都用,这比花里胡哨的功能重要得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进