做图像训练的人大多有过这种体验:GPU 利用率曲线像心电图,一会儿冲到 90%,一会儿掉到 20%,盯着 profiler 一看,占大头的不是矩阵乘,而是 CPU 端的 JPEG 解码。我最早注意到这件事是在一个分类任务上,模型本身没怎么改,只是把 DataLoader 的 worker 从 8 加到 16,吞吐直接涨了将近四成。那时候我就开始琢磨一个更激进的问题:既然 JPEG 解码这么贵,那我能不能不解码,直接拿压缩域里的 DCT 系数去喂网络?
这个思路就是“用 DCT 训练 CNN”。它做的事情说起来很直白:跳过 JPEG 解码链路里的 IDCT、色度上采样、色彩空间转换、浮点重采样这几步,把熵解码出来的 8x8 量化 DCT 系数直接当成网络输入,再把第一层卷积改造成与之匹配的形式。它解决的问题是训练管线的 CPU 瓶颈和内存带宽,不是模型结构创新。适合谁来读?如果你手上是 ImageNet 级别的海量 JPEG 数据、训练卡在数据加载上、又不想牺牲太多精度,那这套东西值得认真试一次;如果你做的是需要亚像素精度的小目标检测或者医学影像分割,那基本可以先划掉,文里我也会说清楚边界在哪。
1. 为什么要在 DCT 系数上直接训练,而不是先解码成 RGB
1.1 训练管线的真实瓶颈:解码器在吃掉你的 GPU 时间
先把这个瓶颈量化一下,不然讨论加速没有意义。一张 224x224 的 JPEG,用最常见的 Pillow 走完整链路(打开、解码、转 RGB、resize 到目标尺寸、转张量),在普通服务器 CPU 上单张耗时大致在 0.4 到 1.0 毫秒这个量级,具体取决于 resize 的插值方式和图像原始分辨率。ImageNet 一个 epoch 一百二十八万张,单核算下来就是 512 秒到 1280 秒,也就是 8 到 21 分钟。
有人会说,多开 worker 不就完了。问题是 worker 数量不是无限的。一台机器 8 核给 16 个 worker,上下文切换和内存拷贝本身就开始吃性能;而且 Python 侧的数据增强(随机裁剪、色彩抖动、mixup)多数是纯 Python 加 numpy,GIL 争用一上来,加 worker 的边际收益掉得很快。我实测过一个典型配置,worker 从 8 加到 16,吞吐只涨了 15%,再往上加基本没变化。
反过来看训练侧。一个 ResNet-50 在 224 输入下,单卡 A100 一个 epoch 大概 5 分钟左右。也就是说数据加载占了总时间的 20% 到 35%,这还是理想情况。如果你做的是小模型、小分辨率、大 batch 的对比学习预训练,这个比例会更难看,数据管线甚至能占到一半以上。所以任何能砍掉解码链路里几步的操作,收益都是实打实的,不是纸面上的。
1.2 从 JPEG 码流到 DCT 系数:中间到底省掉了哪几步
把 JPEG 的完整解码链路拆开,你会清楚地看到哪几步是“必须的”,哪几步是“我们为了拿到像素才不得不做的”。熵解码(Huffman 或算术编码)是必须的,因为你总得把比特流变成数字;反量化是可选的,因为量化系数乘以量化表就得到 DCT 系数,而网络其实可以直接吃量化系数;IDCT 是可以完全跳过的;色度上采样可以跳过,只要你能接受 Cb/Cr 停留在更粗的格点上;YCbCr 到 RGB 的色彩转换可以跳过,网络在 YCbCr 上一样能学;最后的 resize 和浮点重采样,只要你把裁剪对齐到 8 像素的块边界,也可以跳过。
我用一张表把两条链路的差异摆出来,这样对比更直观:
| 处理阶段 | 传统像素域管线 | DCT 域管线 | 说明 |
|---|---|---|---|
| 文件读取 | 必须 | 必须 | 两者相同 |
| 熵解码 | 必须 | 必须 | 都是主要 CPU 开销 |
| 反量化 | 解码器内部隐式完成 | 显式完成,可省略或做归一化 | DCT 域多了一次可控的归一化机会 |
| IDCT(8x8 逆变换) | 必须 | 跳过 | 省掉的浮点运算量相当可观 |
| 色度上采样 | 必须 | 跳过 | 作为通道对齐问题处理 |
| YCbCr 转 RGB | 必须 | 跳过 | 三个分量直接当三组通道 |
| resize / 随机裁剪 | 浮点重采样 | 块对齐裁剪为纯索引操作 | 省掉 bicubic 的浮点计算 |
| 数据增强 | 像素级重采样 | 系数符号翻转、块顺序重排 | 从浮点运算降级为整数操作 |
省掉的这几步里,IDCT 和 resize 是大头。IDCT 是每个 8x8 块一次 64 点二维变换,一张 224x224 的图有 784 个亮度块加 392 个色度块,加起来一千多次二维变换。resize 更贵,双三次插值每个输出像素要读周围 16 个输入像素做加权。这两块加起来,通常能占到整个解码链路 CPU 时间的四到六成。
1.3 DCT 域训练的适用边界:什么任务适合,什么任务别碰
这套方案不是万能的,我先把自己的判断标准摊开,省得你踩坑。核心矛盾只有一个:DCT 系数的空间分辨率天然是原图的八分之一,一个 8x8 的块变成了空间维度上的一个“点”。所有依赖块内精细空间结构的任务,都会在这里受损。
| 任务类型 | 适配程度 | 主要原因 |
|---|---|---|
| 图像分类(ImageNet 级别) | 高 | 语义信息主要在低频,块内细节损失可接受 |
| 大规模预训练 backbone | 高 | 学到的表征对下游迁移友好,还顺带学了压缩伪影 |
| 通用目标检测(大中目标) | 中 | 检测框回归需要一定空间精度,建议 DCT stem 后尽快恢复到高分辨率 |
| 小目标检测 / 密集预测 | 低 | 8x8 块内的小目标信息被严重压缩 |
| 语义分割 / 抠图 | 低 | 边界精度直接受损 |
| 端侧推理部署 | 高 | 省解码意味着省功耗,收益明显 |
| 数据本身是 PNG / 无损格式 | 无意义 | 没有 DCT 系数可取 |
还有一个经常被忽略的边界:JPEG 的质量因子。你训练集里如果混着质量因子 30 和 95 的图,高频系数的分布差异会非常大,不做归一化的话,网络会学到一个对质量因子敏感的表征。这个后面第 2 章会专门讲怎么处理。
2. JPEG 编码链路拆开看:DCT 系数的数据结构与数值特性
2.1 分块、变换、量化:一次说清 8x8 块里发生了什么
JPEG 的第一步是 level shift,把 8 位无符号的像素值减 128,变成 [-128, 127] 的有符号范围。这一步极其关键,很多从零实现的人就是漏了它,导致 DC 系数整体偏了一个常数,网络怎么训都不收敛。
然后是 8x8 分块做二维 DCT。公式写出来是这样:
D(u,v) = (1/4) · C(u) · C(v) · Σx Σy f(x,y) · cos((2x+1)uπ/16) · cos((2y+1)vπ/16)
其中 C(0) = 1/√2,其余 C(k) = 1。这个变换本身是无损的、可逆的,把 64 个像素值映射成 64 个频率系数。左上角 D(0,0) 是 DC 系数,等于块内所有像素之和除以 8,在 level shift 之后取值范围大致是 -1024 到 1016;其余 63 个是 AC 系数,越往右下角频率越高,数值通常越小。
真正造成信息损失的是量化:q(u,v) = round(D(u,v) / Q(u,v)),Q 就是量化表。人眼对高频不敏感,所以高频对应的 Q 值给得很大,量化后大量高频系数直接变成 0。解码端的反量化是 D'(u,v) = q(u,v) · Q(u,v),注意这是有损的,D' 不等于原始的 D,但你拿到的就是 D',网络也只能吃 D'。
关键在于:你可以只做到反量化这一步就停下来。量化系数 q 本身、或者反量化后的 D',都能直接当网络输入,完全不需要往下走 IDCT。
2.2 4:2:0 下采样与 MCU:为什么你的张量形状对不齐
绝大多数 JPEG 用的是 4:2:0 色度下采样。这里的组织单位叫 MCU(最小编码单元),一个 MCU 覆盖 16x16 像素,包含 4 个亮度 8x8 块、1 个 Cb 块、1 个 Cr 块。所以亮度的块数是色度的四倍:一张 224x224 的图,亮度是 28x28 个块,Cb 和 Cr 各是 14x14 个块。
这就是很多人第一次拼张量时形状对不上的原因。你不能简单地把三个分量堆成 (3, 28, 28, 8, 8),因为色度只有 (14, 14, 8, 8)。有两种处理方式:一是把色度块在空间维度上复制一份(最近邻上采样),得到和亮度一样的 28x28,代价是色度信息被重复了四次,参数量上去了但信息量没上去;二是让色度和亮度保持各自的格点,网络里用两套不同分辨率的支路,最后再融合。我倾向第一种,实现简单,而且在分类任务上实测差异不到 0.3 个点。
顺带说一句 4:2:2 和 4:4:4。4:2:2 是水平方向减半,色度块数是亮度的一半;4:4:4 是三者完全一致,处理起来最省心。如果你的数据集是自己生成的,能在预处理阶段统一成 4:4:4 会省掉很多麻烦,代价是文件大一点。
2.3 量化表决定数值尺度,反量化与归一化怎么做
这是最容易翻车的地方。同一张图,用质量因子 95 存和用质量因子 50 存,量化表完全不同,导致同一个系数位置上的数值量级能差一个数量级。你把这两种样本混在一个 batch 里,BatchNorm 的统计量会非常混乱。
我的做法是引入一张参考量化表 Q_ref,把反量化后的系数再除一次:
d_norm(u,v) = q(u,v) · Q(u,v) / Q_ref(u,v)
Q_ref 用 JPEG 标准附录里那张基准亮度表和基准色度表,按质量因子 75 展开(也就是标准表本身)。这一步的物理意义是:把所有样本的系数尺度归一到“假设它是以质量因子 75 压缩”的坐标系里。低频系数因为 Q 值小,除完基本不变;高频系数因为 Q 值大,除完会被轻微放大,正好补偿了高质量图像高频能量偏弱的问题。
实测下来,加不加这一步,跨质量因子的分类精度差 2 到 4 个点。而且它几乎不增加计算量,就是在 GPU 上做一次逐元素乘除,量化表只有 64 个数,可以预先算好倒数的常数张量。
2.4 稀疏性与存储格式:int16、稀疏 CSR 还是直接 float32
量化后的高频系数大量为 0。我统计过一批质量因子 75 的图,平均每个 8x8 块的非零系数在 12 到 18 个之间,也就是说稀疏度在 70% 以上。质量因子 90 的话非零数升到 30 到 50,质量因子 30 的话只有 3 到 8 个。
看到稀疏度高,第一反应是用稀疏格式存。我试过,结论是别用。CSR 或者 COO 格式需要额外的索引数组,反序列化的开销加上 GPU 上稀疏算子对小规模稠密张量的低效,整体比直接存稠密 int16 还慢。这个场景下稀疏性真正的价值是“可以压得更小”,而不是“算得更快”。
实际的存储选择:
| 格式 | 单图大小(224x224,4:2:0) | 优点 | 缺点 |
|---|---|---|---|
| uint8 像素(解码后) | 约 147 KB | 通用 | 需要完整解码 |
| float32 像素 | 约 588 KB | 直接喂网络 | 需要完整解码,内存大 |
| int16 反量化系数 | 约 147 KB | 无需 IDCT,精度足够 | GPU 侧要转 float32 |
| int8 量化系数 q | 约 75 KB | 最省内存 | 必须带量化表,GPU 侧反量化 |
| float32 反量化系数 | 约 294 KB | 直接用 | 传输量偏大 |
注意 int16 就够了。DC 系数范围在 ±1024 附近,AC 系数最大也就几百,int16 的 ±32767 完全覆盖。用 int8 存 q 的时候要注意,量化系数本身的取值范围可以超 255,得先确认你的数据集的极值。
3. 把卷积搬进 DCT 域:网络结构怎么改
3.1 卷积与 DCT 的可交换性:为什么第一层能写成 1x1 卷积
这是这套方法最漂亮的地方,值得花点篇幅讲清楚。DCT 是一个线性变换,而且是对每个 8x8 块独立、用同一组基做的变换。线性意味着它和很多操作可以交换顺序,逐块独立意味着块与块之间不干扰。
先看最简单的逐点操作:如果一个操作对每个像素独立作用(比如逐点缩放、逐点加偏置),那它和 DCT 显然可以交换。再看卷积:如果卷积核大小是 8 的整数倍、步长也是 8 的整数倍,那么每个块内的卷积结果只依赖这个块本身(以及相邻块,如果核跨块的话)。也就是说,在块格点上的一次 1x1 卷积,等价于在空间域里对每个 8x8 块做一次相同的线性映射。
这个等价关系直接把第一层的形式定下来了:把 64 个 DCT 系数看成 64 个通道,块的位置 (i, j) 看成空间坐标,那么第一层就是一个输入通道数为 3×64=192 的 1x1 卷积。输出通道数你可以自由定,通常取 64 或 128。
如果核大小超过 8(比如你想用 16x16 的感受野),那就需要在块格点上用 2x2 的卷积核,或者在 DCT 域先做块合并。这个后面讲。
3.2 DCT stem 的具体实现与分辨率取舍
输入张量的形状是 (B, 3, 28, 28, 8, 8),重排成 (B, 192, 28, 28) 之后送进 1x1 卷积。这里有个细节:系数的通道顺序。是按“先通道后频率”(Y 的全部 64 个系数,然后 Cb 的 64 个,然后 Cr 的 64 个)还是“先频率后通道”?两种都行,只要训练和推理一致。我习惯前者,因为调试的时候按通道切片看比较直观。
真正要费心的是分辨率。原来 ResNet 的 stem 是把 224 降采样到 56x56(7x7 卷积步长 2 得到 112,再 maxpool 得到 56),然后 stage2 在 56x56 上跑。DCT stem 的天然输出是 28x28,比 56 小一半。你有三个选择:
一是接受 28x28,把后面所有 stage 的分辨率整体减半,算力大幅下降(大概省掉 60% 的 FLOPs),但分类精度会掉 1 到 2 个点,检测任务基本别想。二是加一个 2 倍上采样把 28x28 恢复到 56x56,再接原来的 stage2。上采样用转置卷积或者最近邻加 3x3 卷积都行。三是干脆不用 224 输入,改用 448 输入,让 DCT stem 输出 56x56。
我推荐第二个。多出来的开销很小(一个 4x4 步长 2 的转置卷积,通道数 64 的时候参数量才四千多),而且是唯一一个能让后续网络结构完全不用改的方案。如果你追求极致速度而且能接受精度损失,第一个方案也很香。
3.3 后续层的接法与上采样策略
stem 之后,后面的网络就是常规 CNN 了,只是输入的特征图带着一层“压缩伪影”的先验。有个细节我踩过:BN 的统计量在这里会有点不稳,因为 DCT 系数的分布比像素值长尾得多,DC 系数的量级是 AC 系数的几十倍。如果你用 BatchNorm 而且 batch size 不大(比如小于 32),最好在 stem 后面加一个 LayerNorm 或者 GroupNorm 做一次归一化,把量级拉平。
另一个选择是把 DCT stem 做成“浅层特征提取器 + 快速回到像素域”的两段式:stem 只输出很少的通道(比如 32),然后一个 8 倍上采样或者像素重排(pixel shuffle)回到 112x112,再接一个标准的 7x7 卷积。这样相当于用 DCT 域的低频信息替代了原始 stem 的一部分工作,省掉的是解码,保留的是空间精度。这条路子对检测任务更友好。
3.4 一个可以直接跑的 PyTorch 代码骨架
下面这段是我自己用过的骨架,去掉了一些任务特定的部分,留了核心。输入约定为 (B, C, H/8, W/8, 8, 8),C=3,色度已经上采样对齐到亮度格点。
import torch import torch.nn as nn N_COEF = 64 class DCTStem(nn.Module): """把 8x8 DCT 系数投影成特征图,并恢复到 2 倍块格点分辨率。""" def __init__(self, in_planes=3, mid_channels=64, out_channels=64): super().__init__() # 每块位置上对 3*64 维系数向量做线性映射 self.proj = nn.Conv2d(in_planes * N_COEF, mid_channels, 1, bias=False) self.norm = nn.GroupNorm(8, mid_channels) self.act = nn.ReLU(inplace=True) # 28x28 -> 56x56 self.up = nn.ConvTranspose2d( mid_channels, out_channels, kernel_size=4, stride=2, padding=1 ) def forward(self, dct): b, c, h, w, bh, bw = dct.shape assert bh == 8 and bw == 8 x = dct.reshape(b, c * N_COEF, h, w) x = self.act(self.norm(self.proj(x))) return self.up(x) class ToyNet(nn.Module): def __init__(self, num_classes=1000): super().__init__() self.stem = DCTStem() self.body = nn.Sequential( nn.Conv2d(64, 128, 3, stride=2, padding=1, bias=False), nn.GroupNorm(16, 128), nn.ReLU(inplace=True), nn.AdaptiveAvgPool2d(1), ) self.fc = nn.Linear(128, num_classes) def forward(self, dct): x = self.stem(dct) x = self.body(x) return self.fc(x.flatten(1))配套的一个自检函数,用来确认你的系数读取没出问题:
def sanity_check(dct_coef, q_table, img_gray): """拿一张灰度图,验证读出来的 DC 系数是否符合 8x8 块均值关系。""" import numpy as np # level shift 之后,DC = 块内像素和 / 8 patch = img_gray[:8, :8].astype("float32") - 128.0 expect_dc = patch.sum() / 8.0 # 反量化 real_dc = dct_coef[0, 0, 0, 0] * q_table[0, 0] print(f"reconstructed={real_dc:.2f}, expected={expect_dc:.2f}")这个检查看起来幼稚,但它能一次性帮你排除 level shift 漏做、量化表配错、系数顺序搞反这三个最常见的坑。我当时排查了两天才发现是余弦表索引顺序的问题,有这么一个十几行的检查能省好几天。
4. 数据管线实操:从 JPEG 文件到 GPU 张量
4.1 读取 DCT 系数的几种方案对比
这是整个方案能否落地的关键。你能不能在 Python 里高效地拿到 DCT 系数,直接决定了加速是正的还是负的。
| 方案 | 输出形态 | 相对吞吐 | 依赖 | 适用场景 |
|---|---|---|---|---|
| Pillow 完整解码 | 像素 | 1.0(基线) | 无 | 对照实验 |
| Pillow draft 缩放解码 | 缩小像素 | 1.5 到 2.0 | 无 | 快速验证 |
| jpegio 类封装 | 量化系数 + 量化表 | 2.0 到 3.0 | libjpeg | 研究验证期 |
| 直接用 turbojpeg 的系数接口 | 量化系数 + 量化表 | 3.0 到 4.5 | 需编译 | 生产环境 |
| GPU 解码 + 系数抽取 | 系数或像素 | 最高 | GPU | 大规模训练 |
我要诚实地说一句:这些倍数是量级估计,不同机器、不同图像、不同质量因子差别很大,你必须自己在目标环境上测。我见过有人在小图上测出 5 倍,换到高分辨率图就只剩 1.5 倍,因为熵解码本身成了瓶颈,而熵解码是省不掉的。
还在研究验证阶段的话,直接用基于 libjpeg 的封装最省事,虽然要编译,但接口清晰,读出来就是量化系数数组加量化表数组,改起来快。我在生产环境下更倾向自己写一层薄薄的 ctypes 绑定,只暴露“打开文件返回系数和量化表”一个函数,避免在 Python 侧做多余的内存拷贝。
还有一个容易忽略的成本:每张图都新建一次解码器实例的代价很大。要在 worker 进程里复用一个实例,或者至少复用底层的缓冲区。这个优化我做下来能省 20% 的时间,比很多算法层面的优化都有效。
4.2 完整的数据加载流程与预处理顺序
顺序搞错就会白干。我的流程是这样的:
第一步,读文件到内存缓冲区,用 mmap 或者一次性 read,避免解码器自己去管文件句柄。
第二步,熵解码得到量化系数 q 和量化表 Q。这一步必须做,省不掉。
第三步,在 CPU 上做块对齐的裁剪。比如你想从一张 500x375 的图里裁一个 224x224 的区域,起点坐标必须是 8 的倍数,宽高也必须是 8 的倍数。这样裁剪就是纯索引操作,把对应位置的块取出来,零成本。如果起点不对齐,你就得先整块取出、再在块内做像素级裁剪,那就必须 IDCT,加速全没了。
第四步,对齐三个分量的格点。4:2:0 的话色度在空间维度上重复一次,变成和亮度一样的块数。
第五步,反量化加归一化。这一步我建议放到 GPU 上做,因为量化表是一个常数张量,在 GPU 上做逐元素乘除几乎免费,而在 CPU 上做会占用宝贵的 worker 时间。CPU 侧只传 int16 的原始系数上去。
第六步,转成 float32,做数据增强,喂网络。
这套流程里最反直觉的地方是第五步。很多人习惯在 CPU 侧把所有预处理做完再传 GPU,但在这个场景下,传 int16 系数比传 float32 系数省一半带宽,而反量化在 GPU 上几乎不花钱。这是个典型的“把便宜的操作放到贵的地方做”的例子。
4.3 在 DCT 域做数据增强:翻转、裁剪、混叠的规则
数据增强是最能体现 DCT 域特性的地方,也是最容易出错的地方。我把几个常用增强的规则列一下:
水平翻转的规则是:块在列方向上顺序反转,同时每个块内凡是水平方向频率索引 u 为奇数的系数取负号。为什么?因为余弦基在 u 为奇数时是奇函数,水平镜像之后,奇数阶谐波自然反相。垂直翻转同理,作用于 v 为奇数的系数。这个操作是纯整数操作,比像素级翻转快得多,我实测能快 5 到 8 倍。
随机裁剪必须在块粒度上做,8 像素对齐。这意味着你的裁剪粒度变粗了,一张 256x256 的图裁 224x224,可选位置从 33x33 变成 5x5(如果按块移动)——不对,是 33x33 个位置里只有 8 的倍数那些是合法的,实际约 17x17 个位置。选择少了,增强的多样性会下降。这个损失要靠其他增强补回来,比如更强的 mixup 或者 cutout。
随机擦除(cutout)在 DCT 域特别好做:直接把某个位置的整块系数置零,等价于把这块区域填成中性灰(因为所有频率能量都为 0 意味着块内像素恒等于 128)。而且你可以只擦低频、保留高频,做出一种“只抹掉颜色但保留纹理”的奇怪效果,这算是一个 DCT 域独有的增强方式,我在小数据集上试过,有一点点涨点。
Mixup 完全合法,因为 DCT 是线性的,两张图的系数按比例混合,等价于像素域混合之后再变换。CutMix 要注意必须是块粒度的矩形,而且边界对齐。
色彩抖动没有直接的对应操作。近似做法是改 Y 分量的 DC 系数(等价于整体调亮度),改 Cb/Cr 的 DC 系数(近似调色度)。这个近似不算精确,但在实践中够用。
4.4 内存与带宽的账:算一遍你省了多少
拿 224x224、4:2:0 的图算一遍。
亮度块数:28x28 = 784,每块 64 个系数,共 50176 个。色度各 14x14 = 196 块,各 12544 个系数。合计 75264 个系数。用 int16 存是 150528 字节,约 147 KB。这跟 uint8 像素的 147 KB 一模一样,比 float32 像素的 588 KB 省了四分之三。
如果只存量化系数 q 用 int8,是 75264 字节,约 73.5 KB,比 uint8 像素还省一半。代价是必须带量化表在 GPU 侧反量化,而且要注意极值溢出。
但我要强调:内存不是这套方案的主要收益来源。真正省下来的是 CPU 时间。IDCT、色度上采样、色彩转换、bicubic resize 这几步加起来,通常能占到整个数据加载耗时的 50% 到 70%。也就是说,如果你的基线数据加载占训练总时间 30%,改完之后能降到 10% 到 15%,整体训练时间缩短 15% 到 20%。
这个数字听起来不性感,但它是免费的——模型结构几乎不用改,精度基本持平,工程上是一次性投入。而且它还有复利效应:当你的数据加载不再是瓶颈,就可以放心地开更大的 batch、用更强的增强、做更多的负样本,这些收益会叠加。
5. 常见问题与排查实录
5.1 精度掉点的六个典型原因
我从头到尾跑了大概七八轮实验,精度掉点几乎每次都踩在不同的坑上。整理一下:
第一是 level shift 漏做。DC 系数整体偏了 1024,网络的输入分布完全错位。症状是 loss 一开始就很大而且降不下来。用一个块的像素均值去验算,五分钟就能确认。
第二是量化表配错。有些库返回的量化表是按 zigzag 顺序排的,而系数是按自然顺序排的,两者直接相乘就全乱了。症状是重建出来的图看着像噪点。处理方法很简单:确认量化表的索引顺序和系数的索引顺序一致,不一致就重排。
第三是归一化没做。跨质量因子的样本混在一起,高频系数的量级差一个数量级,BN 统计量被拉爆。第 2.3 节那个除以 Q_ref 的操作就是治这个的。
第四是色度上采样方式不一致。训练时用最近邻,推理时用双线性,精度会莫名掉一两个点。这种 bug 最难查,因为两边代码看着都对。建议把上采样方式写成一个配置项,训练和推理强制走同一个函数。
第五是 BN 的 running stats 在 DCT 域不适用。像素域的均值和方差统计跟 DCT 域完全是两回事。如果你是从预训练权重开始微调,那些 running stats 会带来负面影响,建议把 stem 后面几层的 BN 重置或者换成 GroupNorm。
第六是增强破坏了块结构。比如你在 DCT 域裁剪之后又做了一次像素级的 resize,那前面的努力全白费,还引入了插值误差。检查办法是在数据管线里打印张量形状,确认全程都是 8 的倍数。
5.2 数据加载报错与形状异常速查表
| 症状 | 高概率原因 | 排查手法 |
|---|---|---|
| reshape 报错,元素数对不上 | 4:2:0 色度块数是亮度的四分之一 | 分别打印三个分量的 shape |
| 第一个 batch 的 loss 是几万 | DC 没做 level shift | 检查 DC 系数的数值范围 |
| 精度比像素域基线低 5 个点以上 | 归一化缺失或量化表错位 | 统计每个系数位置的均值方差 |
| 训练能跑但不收敛 | 学习率偏大,DCT 域梯度尺度不同 | 学习率先除以 5 再试 |
| 数据加载比基线还慢 | worker 里重复创建解码器 | 用进程内单例复用 |
| 灰度图崩掉 | 单通道 JPEG 没有色度分量 | 加一个单通道分支补零 |
| 某些图读出来是纯灰 | 渐进式 JPEG 或特殊采样因子 | 单独列出异常文件统计 |
| 验证集精度远低于训练集 | 训练用了块对齐裁剪,验证没有 | 统一两侧的预处理 |
5.3 几个我踩过的坑
第一个坑是渐进式 JPEG。有些数据集里混了渐进式编码的文件,解码器对它的处理路径不一样,读取系数的接口可能直接返回空或者报错。我当时的做法是在数据预处理阶段做一次全量扫描,把渐进式的文件转成基线式,一次转好,后面就不用管了。
第二个坑是 EXIF 旋转。手机拍的图经常带旋转标记,像素域的库会自动应用这个标记,但直接读 DCT 系数的时候不会。结果就是一部分图的方向是错的,模型学到了一个跟方向相关的虚假特征。这个 bug 特别隐蔽,因为 loss 曲线看着完全正常,只有看混淆矩阵才发现某些类别的预测系统性偏移。
第三个坑是边缘 padding。图像宽高不是 8 的倍数时,JPEG 编码器会在右边缘和下边缘补齐,补齐的像素值通常是重复最后一列或最后一行。读出来的系数里就包含了这些 padding 块。如果你不做处理直接喂网络,模型会看到一堆“人工”的块。解决方法是把有效块数记下来,padding 块直接丢弃或者标记掉。
第四个坑是自定义量化表。标准量化表是按质量因子线性缩放出来的,但很多工具允许用户传自定义的量化表,甚至是每个块用不同的表(虽然少见)。如果你的数据来自多个来源,一定要做一次量化表的聚类统计,看看有几种不同的表。我遇到过一批图,量化表的低频项被改得很小,导致 DC 系数的量级和别的图差了 4 倍,归一化之后才对齐。
这套东西我做下来最大的体会是:它不是一个“换个输入格式”的小改动,而是一次对数据管线的重新设计。真正花时间的不是网络结构(那部分一两百行就写完了),而是把读取、对齐、归一化、增强这四件事在 DCT 域上重新想一遍。每一个环节都有它的坑,但每个坑填完之后,收益是确定性的。如果你现在正被数据加载卡着,而且手上是海量 JPEG,我建议先花两天做一个最小验证:拿一万张图,跑一遍完整的读取加 stem 前向,测一下吞吐和一次小规模的收敛曲线。两天时间,换来的是后面几个月训练时间的缩短,这笔买卖怎么算都值。