资讯详情 SPAN图像篡改定位实战:空间金字塔注意力机制解析与复现
📅 2026/11/3 4:08:51
图像篡改定位这个方向我断断续续跟了两年多。早期方法大多依赖手工特征比如噪声残差、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, reduction16): super().__init__() self.avg_pool nn.AdaptiveAvgPool2d(1) self.max_pool nn.AdaptiveMaxPool2d(1) self.mlp nn.Sequential( nn.Conv2d(channels, channels // reduction, 1, biasFalse), nn.ReLU(inplaceTrue), nn.Conv2d(channels // reduction, channels, 1, biasFalse) ) 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_size7): super().__init__() self.conv nn.Conv2d(2, 1, kernel_size, paddingkernel_size // 2, biasFalse) self.sigmoid nn.Sigmoid() def forward(self, x): avg_out torch.mean(x, dim1, keepdimTrue) max_out, _ torch.max(x, dim1, keepdimTrue) attn self.sigmoid(self.conv(torch.cat([avg_out, max_out], dim1))) return x * attn上面这段是我按论文描述重写的注意力模块和官方实现思路一致。注意通道注意力的 reduction 取 16 是个经验值通道数少的时候比如 64可以取 8否则 MLP 压缩太狠会丢信息。3. 从论文公式到可跑代码的关键环节3.1 骨干特征的选择取哪一层论文没有死磕骨干网络用的是常规的分类骨干去掉全连接。关键问题是取哪一层的特征送入金字塔。取太浅特征语义弱但空间分辨率高取太深语义强但分辨率低小篡改早没了。我的做法是取 stage3 和 stage4 的输出做融合stage4 上采样后和 stage3 拼接。stage3 在 512 输入下是 64x64stage4 是 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, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue) )) self.fuse nn.Sequential( nn.Conv2d(in_channels out_channels * len(pool_sizes), out_channels, 1, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue) ) 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), modebilinear, align_cornersFalse) feats.append(out) return self.fuse(torch.cat(feats, dim1))3.3 损失函数为什么不能只用交叉熵纯交叉熵在篡改定位上有个明显问题篡改区域通常只占整图很小比例正负样本极度不平衡模型倾向于全判负样本也能拿到很低的 loss。我第一版复现就栽在这训练 loss 一路降到 0.05但预测出来几乎全黑。论文用了加权交叉熵加 Dice 损失的组合。加权交叉熵给正样本更高权重缓解不平衡Dice 损失直接优化区域重叠度对边界更敏感。权重我按正负样本比例动态算而不是固定值这样在不同数据集上迁移更稳。另外加了一个边界加权的技巧对篡改区域边缘几个像素内的点给更高权重因为边界是最容易判错的地方。损失组合像素 F1边界 IoU备注纯交叉熵0.610.43严重偏向负样本加权交叉熵0.720.55需调权重加权交叉熵 Dice0.790.64论文方案上述 边界加权0.810.69我的改进3.4 训练策略里的几个实测参数学习率用 1e-4 配 Adambatch 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 层只训练后面的模块。我选了 GroupNormgroup 数取 32训练立刻稳了。这个经验对做小数据集复现的同学应该有用——别迷信 BNbatch 小于 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 的模块不算多但每个都有作用按这个顺序走既能复现出接近论文的结果也能真正理解每个设计为什么存在。我见过不少人直接拉官方代码跑一遍就完事换个数据集就抓瞎问题就出在没搞清楚模块和任务之间的对应关系。