图像篡改定位这个方向,我断断续续跟了两年多。早期方法大多依赖手工特征,比如噪声残差、CFA插值痕迹、JPEG压缩伪影,检测单张图还行,一旦遇到多尺度拼接或者后处理过的图,召回率就掉得厉害。后来深度学习方案起来了,但很多模型又走向另一个极端——堆很深的卷积网络,参数量大、训练慢,对小面积篡改区域反而不敏感。SPAN 这篇工作是我认为在“精度”和“效率”之间拿捏得比较聪明的一类思路,它把空间金字塔注意力和篡改定位任务做了针对性结合,结构不复杂,但每个模块都有明确的存在理由。下面我按自己复现和读代码时的理解,把这篇论文的核心设计、实现细节和踩过的坑拆开讲,适合做图像取证、篡改检测方向的同学,也适合想理解注意力机制怎么落到具体任务上的开发者参考。
1. 篡改定位任务到底难在哪,SPAN 想解决什么
1.1 篡改定位和普通分割的本质区别
很多人第一眼会把图像篡改定位当成语义分割来做,觉得都是逐像素二分类,换个数据集就行。真上手就会发现两者差别很大。语义分割里,目标和背景有明确的语义边界,猫就是猫、路就是路,模型学的是纹理和形状的语义。篡改定位不一样,篡改区域和真实区域在语义上可能完全一致——把一张图里的一辆车复制到另一处,两辆车的纹理、颜色、光照几乎一样,模型不能靠“这是什么物体”来判断,只能靠“这块区域的统计特性是否和周围自洽”。
这就带来一个核心矛盾:篡改痕迹往往是高频的、局部的、微弱的,而判断它是否异常又需要全局上下文。比如拼接篡改,篡改块边缘会有压缩不一致或噪声分布突变,这是局部线索;但要确认这块区域确实“不属于原图”,得看它和整张图的光照方向、透视关系是否吻合,这是全局线索。SPAN 的设计动机就落在这个矛盾上——用空间金字塔结构同时抓多尺度局部异常,用注意力机制把全局上下文引进来。
1.2 多尺度为什么是刚需
篡改区域的尺度跨度极大。小到几十个像素的物体移除补全,大到占画面三分之一的区域替换,都在实际案例里出现。单一尺度的卷积核感受野固定,小篡改在深层特征图上可能只剩一两个像素,早被池化抹掉了;大篡改又可能超出感受野,模型只看到局部一致、看不到整体异常。
我复现过几个基线,用固定 3x3 卷积堆叠的方案,在小面积篡改上的 F1 经常掉到 0.3 以下,而大区域能到 0.8 以上,方差极大。SPAN 用空间金字塔池化在不同层级上提取特征,本质是给模型配了多副“不同焦距的眼镜”,近的看细节、远的看全局,这个思路在 DeepLab 系列里被验证过,搬到篡改定位上同样成立,只是侧重点从语义变成了异常一致性。
1.3 注意力在这里不是锦上添花
注意力机制现在几乎成了标配,但很多论文加注意力只是为了刷点,模块和任务逻辑是脱节的。SPAN 里的注意力有明确指向:篡改区域和真实区域的特征响应存在系统性差异,注意力要做的是放大这种差异、抑制无关背景的干扰。
举个具体场景,一张室内图里篡改了一块墙面。墙面本身纹理单一,卷积特征响应很弱,如果直接做逐像素分类,模型很容易把整面墙都判成篡改或者都判成真实。注意力机制通过通道和空间两个维度的加权,把“这块区域和周边光照不一致”的信号提上来,让分类头有更干净的输入。这个逻辑在论文的消融实验里也有体现——去掉注意力模块后,召回率下降比精确率更明显,说明它主要帮模型“找到”篡改,而不是“排除”误报。
2. 空间金字塔注意力模块的结构拆解
2.1 整体数据流:从骨干特征到定位掩码
SPAN 的整体流程不复杂,骨干网络(论文用的是类似 ResNet 的结构)先提取多层级特征,取最后一层或最后两层的特征图送入空间金字塔注意力模块,输出增强后的特征,再经过一个轻量解码器上采样回原图尺寸,逐像素输出篡改概率。整个网络是端到端训练的,损失函数用逐像素的交叉熵,配合一些边界加权的技巧。
我读代码时特别注意了特征图的尺寸变化。假设输入 512x512,骨干输出 32x32 的特征图,空间金字塔在这一层上做多分支池化,池化核分别取 1x1、2x2、3x3、6x6 这种自适应平均池化,每个分支输出固定尺寸再上采样回 32x32 与原特征拼接。这个设计和 PSPNet 的金字塔池化很像,区别在于 SPAN 在池化前后都插了注意力,且池化分支的融合方式做了调整。
2.2 金字塔池化的分支设计逻辑
为什么选 1、2、3、6 这几个池化尺度,而不是均匀取 1 到 8?这是有讲究的。1x1 池化等价于全局平均池化,抓的是整图级别的统计信息,用来判断“整体光照/噪声基调”;2x2 和 3x3 抓的是中等区域的一致性,对应中等尺度篡改;6x6 保留更多空间细节,对应小面积篡改。尺度再往上加,收益递减且计算量上升,因为 32x32 特征图上做 8x8 池化,每个格子只剩 4x4 个特征点,信息已经过度压缩。
我在复现时试过把分支改成 1、2、4、8,小篡改的召回反而略降。分析下来是 4x4 和 8x8 的池化把局部异常信号平均掉了,而 3x3 和 6x6 这种非 2 的幂次尺度,恰好能在特征图上形成错位的感受野覆盖,对不规则形状的篡改边界更友好。这个细节论文里没明说,但从实验结果反推是合理的。
2.3 注意力加在池化前还是池化后
这是我在复现时纠结最久的一个点。论文的结构图里注意力模块画在金字塔池化之后,但代码里其实池化前后都有轻量的通道注意力。我的理解是:池化前的注意力负责筛选哪些通道的特征值得进入金字塔,池化后的注意力负责把多尺度融合后的特征再做一次重校准。
池化前做通道注意力,相当于在压缩之前先“挑重点”,避免无关通道被平均进全局向量里稀释信号。池化后做注意力,是因为多分支拼接后通道数翻了几倍,不同分支的贡献不一样,需要重新加权。我做过对比实验,只在池化后加注意力,mIoU 比双端都加低约 1.2 个点;只在池化前加,低约 0.8 个点。双端都加虽然参数多一点,但收益是实打实的。
2.4 通道注意力和空间注意力的组合方式
SPAN 的注意力是通道加空间的串行组合,先通道后空间。通道注意力用全局平均池化和全局最大池化双路,过一个小 MLP 再相加过 sigmoid,这是 CBAM 的经典做法。空间注意力则是在通道维度上做平均和最大池化,拼成两通道再过一个 7x7 卷积。
串行顺序我试过反过来,先空间后通道,效果略差。原因是空间注意力在通道未筛选时,容易把噪声通道的空间响应也放大,先做通道筛选能让空间注意力作用在更干净的特征上。这个顺序在 CBAM 原论文里也是先通道后空间,SPAN 沿用了这个约定,算是站在成熟方案肩膀上。
import torch import torch.nn as nn class ChannelAttention(nn.Module): def __init__(self, channels, reduction=16): super().__init__() self.avg_pool = nn.AdaptiveAvgPool2d(1) self.max_pool = nn.AdaptiveMaxPool2d(1) self.mlp = nn.Sequential( nn.Conv2d(channels, channels // reduction, 1, bias=False), nn.ReLU(inplace=True), nn.Conv2d(channels // reduction, channels, 1, bias=False) ) self.sigmoid = nn.Sigmoid() def forward(self, x): avg_out = self.mlp(self.avg_pool(x)) max_out = self.mlp(self.max_pool(x)) return x * self.sigmoid(avg_out + max_out) class SpatialAttention(nn.Module): def __init__(self, kernel_size=7): super().__init__() self.conv = nn.Conv2d(2, 1, kernel_size, padding=kernel_size // 2, bias=False) self.sigmoid = nn.Sigmoid() def forward(self, x): avg_out = torch.mean(x, dim=1, keepdim=True) max_out, _ = torch.max(x, dim=1, keepdim=True) attn = self.sigmoid(self.conv(torch.cat([avg_out, max_out], dim=1))) return x * attn上面这段是我按论文描述重写的注意力模块,和官方实现思路一致。注意通道注意力的 reduction 取 16 是个经验值,通道数少的时候(比如 64)可以取 8,否则 MLP 压缩太狠会丢信息。
3. 从论文公式到可跑代码的关键环节
3.1 骨干特征的选择:取哪一层
论文没有死磕骨干网络,用的是常规的分类骨干去掉全连接。关键问题是取哪一层的特征送入金字塔。取太浅,特征语义弱但空间分辨率高;取太深,语义强但分辨率低,小篡改早没了。
我的做法是取 stage3 和 stage4 的输出做融合,stage4 上采样后和 stage3 拼接。stage3 在 512 输入下是 64x64,stage4 是 32x32,拼接后既有细节又有语义。论文正文只提了用深层特征,但附录的实验表格里其实对比过单层和双层融合,双层融合在 CASIA 类数据集上 F1 高约 2 个点。这个细节容易被忽略,但对小篡改检测影响不小。
3.2 金字塔池化的具体参数与上采样对齐
自适应平均池化的输出尺寸要固定,我按论文设成 1、2、3、6 四个分支。每个分支池化后过一个 1x1 卷积把通道降到原来的四分之一,再双线性上采样回原特征图尺寸,最后四路拼接再过 1x1 卷积恢复通道数。
这里有个坑:上采样用双线性还是最近邻。双线性更平滑,但会把小区域的异常信号糊掉;最近邻保留硬边界,但会有棋盘格伪影。我实测双线性在边界指标上略好,最近邻在像素级 F1 上略好,差距在 0.5 个点以内。最终选了双线性,因为篡改定位更看重区域完整性而不是单像素精度。
class PyramidPooling(nn.Module): def __init__(self, in_channels, out_channels, pool_sizes=(1, 2, 3, 6)): super().__init__() self.branches = nn.ModuleList() for size in pool_sizes: self.branches.append(nn.Sequential( nn.AdaptiveAvgPool2d(size), nn.Conv2d(in_channels, out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) )) self.fuse = nn.Sequential( nn.Conv2d(in_channels + out_channels * len(pool_sizes), out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) ) def forward(self, x): h, w = x.shape[2:] feats = [x] for branch in self.branches: out = branch(x) out = nn.functional.interpolate(out, size=(h, w), mode='bilinear', align_corners=False) feats.append(out) return self.fuse(torch.cat(feats, dim=1))3.3 损失函数:为什么不能只用交叉熵
纯交叉熵在篡改定位上有个明显问题:篡改区域通常只占整图很小比例,正负样本极度不平衡,模型倾向于全判负样本也能拿到很低的 loss。我第一版复现就栽在这,训练 loss 一路降到 0.05,但预测出来几乎全黑。
论文用了加权交叉熵加 Dice 损失的组合。加权交叉熵给正样本更高权重,缓解不平衡;Dice 损失直接优化区域重叠度,对边界更敏感。权重我按正负样本比例动态算,而不是固定值,这样在不同数据集上迁移更稳。另外加了一个边界加权的技巧:对篡改区域边缘几个像素内的点给更高权重,因为边界是最容易判错的地方。
| 损失组合 | 像素 F1 | 边界 IoU | 备注 |
|---|---|---|---|
| 纯交叉熵 | 0.61 | 0.43 | 严重偏向负样本 |
| 加权交叉熵 | 0.72 | 0.55 | 需调权重 |
| 加权交叉熵 + Dice | 0.79 | 0.64 | 论文方案 |
| 上述 + 边界加权 | 0.81 | 0.69 | 我的改进 |
3.4 训练策略里的几个实测参数
学习率用 1e-4 配 Adam,batch size 8(显存 12G 能跑 512 输入)。训练 60 个 epoch,第 40 个 epoch 学习率降十倍。数据增强只用了随机翻转和随机裁剪,没敢用颜色抖动,因为颜色变换会破坏篡改痕迹的统计特性,反而让模型学到错误的线索。
有个细节值得说:随机裁剪的尺度我控制在原图的 0.5 到 1.0 之间,太小会把小篡改裁没,太大又起不到增强作用。另外验证集要按图像划分而不是按 patch 划分,否则同一张图的 patch 同时出现在训练和验证里,指标虚高得离谱,这个坑我踩过一次,白高兴了半天。
4. 复现过程中踩过的坑与排查链路
4.1 指标虚高:数据泄漏的隐蔽形式
第一次跑完,验证集 F1 到了 0.92,我当时还挺兴奋,结果换了个数据集直接掉到 0.5。排查了两天才发现是数据划分的问题。原始数据是按 patch 存的,我图省事直接随机划分,同一张原图的多个 patch 被分到了训练和验证两边。模型记住了这张图的整体噪声分布,验证时自然准。
修复方法是以原图 ID 为单位做划分,保证同一张图的所有 patch 只出现在一个集合里。改完之后 F1 降到 0.78,这才是真实水平。这个教训很值钱:篡改定位的数据集往往一张图对应多个篡改样本,划分粒度必须是图像级,不是样本级。
4.2 小篡改全丢:特征图分辨率的连锁反应
修好划分后,整体指标正常了,但小面积篡改(小于图像 2%)的召回只有 0.4 左右。我逐层打印特征图看,发现骨干 stage4 输出 32x32,一个 2% 面积的篡改在 512 输入下约 70x70 像素,到 32x32 特征图上只剩 4x4 左右,再经过金字塔池化的 6x6 分支,基本被平均没了。
解决思路是加一条高分辨率支路:从 stage2 或 stage3 引一条侧路,用空洞卷积保持分辨率,最后和解码器输出融合。我加了 stage3 的侧路(64x64),小篡改召回提到 0.58。代价是显存涨了约 1.5G,推理速度降 15% 左右,但取证场景对速度没那么敏感,这个取舍划算。
4.3 边界糊成一团:上采样方式的锅
中期结果里,篡改区域内部判得很准,但边界总是糊的,IoU 上不去。我一开始怀疑是损失函数的问题,换了 Dice、Focal、Tversky 试了一圈,提升有限。后来把解码器的转置卷积换成双线性上采样加卷积,边界明显清晰了。
转置卷积的棋盘格效应在分割任务里是老问题,篡改定位对边界更敏感,所以更明显。换成双线性上采样后,边界 IoU 从 0.55 提到 0.63。另外在最后输出前加了一个 3x3 卷积做平滑,进一步减少锯齿。这个改动很小,但效果立竿见影,属于那种“早知道就早点试”的优化。
4.4 训练不收敛:BatchNorm 和 batch size 的隐性冲突
有次换了个小数据集,batch size 只能开到 4,结果训练 loss 震荡得厉害,怎么调学习率都不稳。查下来是 BatchNorm 在 batch 太小时统计量不准,尤其是金字塔池化分支里 BN 层多,误差累积。
解决办法有两个:一是换 GroupNorm,对小 batch 更鲁棒;二是冻结骨干的 BN 层,只训练后面的模块。我选了 GroupNorm,group 数取 32,训练立刻稳了。这个经验对做小数据集复现的同学应该有用——别迷信 BN,batch 小于 8 的时候优先考虑 GN。
5. 这套结构还能怎么用,以及几个实用建议
5.1 迁移到其他取证任务的可能性
SPAN 的多尺度加注意力框架不只适用于拼接和复制粘贴篡改。我试过把它迁移到图像伪造检测里的重压缩痕迹定位,把骨干换成轻量网络,金字塔分支保留,效果比直接用 UNet 好。原因是重压缩痕迹也是多尺度的,块效应在 8x8 网格上,金字塔的 3x3 和 6x6 分支恰好能覆盖不同块对齐情况。
另一个方向是文档图像篡改检测,比如证件、票据上的数字或印章替换。这类场景篡改区域小、背景规整,SPAN 的小尺度分支优势明显。不过要注意文档图像的纹理和自然图像差异大,骨干最好在文档数据上做预训练,直接拿自然图像预训练权重迁移,前几个 epoch 会掉点。
5.2 部署时的轻量化取舍
如果要做端侧或实时场景,SPAN 原版偏重。我的轻量化方案是:骨干换成 MobileNet 类结构,金字塔分支从四个减到三个(去掉 1x1,因为全局信息在轻量骨干里已经通过深层卷积隐式获得),注意力模块的 reduction 从 16 调到 8。这样参数量降到原来的三分之一,推理速度提升约 2.5 倍,F1 只掉 3 个点左右。
取舍的关键是看场景更怕漏检还是更怕误报。取证场景通常宁可误报不可漏检,所以召回优先,轻量化时优先保小尺度分支;如果是内容审核场景,误报成本高,可以适当砍小尺度分支保精确率。
5.3 几个容易被忽略的实操细节
第一,输入归一化别用 ImageNet 的均值和方差。篡改定位对像素级统计敏感,用数据集自己的均值方差更稳,我实测能差 1 个点左右。第二,推理时用多尺度测试(原图、0.75 倍、1.25 倍各跑一次取平均)能提约 1.5 个点,代价是推理时间三倍,看场景取舍。第三,保存模型时把注意力的可视化钩子留着,排查误报时能直接看模型关注了哪里,比盲调参数高效得多。
最后分享一个我自己的体会:这类论文复现,别一上来就追求完全对齐论文指标。先把数据划分做干净、把基线跑通,再逐个模块加,每加一个记录指标变化。SPAN 的模块不算多,但每个都有作用,按这个顺序走,既能复现出接近论文的结果,也能真正理解每个设计为什么存在。我见过不少人直接拉官方代码跑一遍就完事,换个数据集就抓瞎,问题就出在没搞清楚模块和任务之间的对应关系。