最近我又被拉去当“图片裁判”,群里发来一张看似平平无奇的街道照片,有人说旁边那辆车是修图修上去的。以前遇到这种事,我得放大看边缘、比对噪点、翻图层,折腾半天还不敢下结论。后来我把图像拼接检测做成一个深度模型,输入一张图,直接输出篡改区域的热力图,省了很多口舌。
图像拼接检测,简单说,就是识别一张图像里是否存在从其他图像剪切粘贴过来的区域。它和复制粘贴检测还不一样:拼接的素材来自另一张图,没有同图里可匹配的克隆块,只能靠“这块内容跟周围环境是不是一家人”来判断。这个方向里,2018年有一篇让我印象很深的工作,题目直译是“对抗假新闻:通过学习自一致性检测图像拼接”,核心就是让网络判断图像的每个局部与整体是否自洽。这个方法的价值在于,它不再死磕JPEG重压缩、CFA插值这类具体伪造痕迹,而是让网络自己学习什么叫“同一幅图的一部分”。
这篇文章我会从思路拆解、网络设计、数据合成、训练复现讲到踩坑实录。如果你在做AI数字取证、图像安全,或者正准备复现一篇计算机视觉方向的论文,这篇内容可以当成一份可执行的参考笔记。
1. 项目概述:先搞清楚“拼接检测”在打什么怪
1.1 拼接不等于复制粘贴
很多人刚接触图像篡改时,会把拼接检测和复制粘贴检测混在一起,其实两个任务差别挺大。复制粘贴是同一个图里的区域搬来搬去,检测时可以找相似块,通过块匹配和特征比对就能拿到高度可疑的重复区域。拼接则是把图像A的一块抠下来,贴到图像B上,来源完全不同,你在图B内部找不到另一个相同的块,也没有办法用“找重复”的思路去解决。
这就带来一个本质困难:伪造者可以把拼接区域做得非常自然,边缘处理得很干净,甚至重新采样、调色、加噪,让肉眼和人造的统计特征都看不出异常。传统算法一旦遇上这种“加工过”的拼接,很容易失效。所以拼接检测需要寻找更底层、更难被抹掉的线索。
那什么是难被抹掉的线索?在我看来,就是“不自然的自洽性缺失”。一张自然照片在光照方向、纹理颗粒、残差噪声、色彩分布上是连续平滑的,哪怕两个不同物体语义不同,它们由同一个成像系统拍出来,底层统计也有一致性。拼接区域来自另一张图,相当于强行插入了外来信号,这种不一致会藏在各种统计量里,只是藏得深浅而已。
1.2 自一致性是一套“内审”逻辑
自一致性这个说法听起来玄,其实可以用一个生活场景解释。一个班里突然来了个外校学生,你不需要知道外校学生的校服长什么样,只要观察他和班里其他学生在口音、习惯、活动范围上的差别,就会发现他“不合群”。图像自一致性也是这个思路:图像里的每个局部,都应该和其他局部保持某种统计上的一致性。如果有人从外面搬了一块内容进来,它在和其他区域比较时就会“对不上”。
2018年那篇工作把这个直觉放进了网络:模型先提取每个位置的特征描述,再让每个位置的描述符和图像全局描述符做比较,生成一张自一致性得分图。得分越低的地方,越可能属于拼接区域。这种设计有个很明显的好处:它不是在认“树”“人”“汽车”这些语义,而是在认“这个像素和整张图是否同源”。
1.3 谁适合复现这个方向
如果你正在做AI安全和内容风控相关研究,或者想理解“图像内部统计一致性”怎么用深度学习建模,这个项目值得上手。复现门槛属于中等偏上,网络结构本身不算复杂,真正卡人的地方是数据构造和训练细节。我是先把合成数据管线跑通,才敢回头调网络结构,否则模型再花哨也没有用。
2. 核心思路与设计选择:为什么是“自一致性”
2.1 手工取证特征为什么越来越不灵
早期图像取证主要靠手工特征。比如JPEG质量差异、噪声残差、CFA插值模式、色差边缘,这些都是“有什么痕迹就找什么”的思路。这类方法在十年前很有用,因为当时的修图工具会留下明显指纹。可现在已经变了:AI修图、双线性重采样、类似压缩擦除的工具能把大部分痕迹抹掉,单一手工特征很容易被反制。
还有一个麻烦是特征之间的组合很繁琐。有些图没有噪声差异,有些图没有JPEG痕迹,你得先判断用哪套特征,再设置阈值,工程上非常脆弱。深度CNN能把这套流程统一起来,但它也有自己的风险:如果单纯用一个分类网络预测篡改区域,网络很可能会学到“看到天空就是底图,看到汽车就是拼接”,因为训练数据里物体分布不均衡,最终变成一个语义分割器,而不是取证工具。
2.2 学习式一致性不预设伪造方式
这篇工作用“自一致性”来绕开上面的坑。它不直接学习“拼接区域长什么样”,而是学习“一个局部和全局之间的一致程度怎么度量”。这个思路可以理解为:把特征提取、距离度量、判定阈值全部丢给网络端到端训练。
传统“全局一致性”也有人做过:把图像分成若干块,手工提取颜色、纹理、噪声特征,算块与块之间的相似度,再用聚类把离群块标出来。学习式自一致性的区别在于,特征是通过任务自己学出来的。网络会在训练过程中发现,最有效的“一致特征”可能不是颜色,也不是噪声,而是一种高维组合信号,这比人手工设计的特征更灵活,也更能对付未知处理方式。
2.3 多尺度是真正拉开差距的地方
拼接区域的大小差别很大。小的可能只有几十个像素,大的能占半张画面。如果只在单一尺度上做一致性判断,小区域容易被下采样抹掉,大区域又可能因为局部上下文太窄而看不全周围关系。所以当时的方案用了多尺度输入:同一张图缩放成几个分辨率,分别过网络,再把生成的一致性得分图拉回原图尺寸融合。
我在复现时明显感觉到多尺度带来的收益。单尺度模型对128像素以下的小拼接块召回率很低,换成三尺度输入之后,小目标的检测分数明显提升。原因也好理解:小拼接块在原始尺度上还有纹理细节,网络有机会抓到它与邻居的差异;而大拼接块在缩小尺度上能更高效地看到全局关系,不容易被局部误导。
3. 核心网络模块与数据构造:纸上得来终觉浅
3.1 网络结构的理解与简化实现
我没有把原始网络结构一成不变地搬下来,而是先画成一个通用框架:骨干特征提取网络 -> 局部描述子投影 -> 全局一致性比较 -> 掩膜预测头。
局部描述子投影相当于给特征图上每个位置生成一个“身份编码”。这个编码要能够表达局部区域的纹理、噪声和语义背景;一致性比较负责拿当前位置的身份编码和全局身份总结比对。最朴素的实现方式是用全局平均池化得到一个全局向量,然后把它和每个位置的局部向量拼接起来,再通过卷积判断是否吻合。原始论文里比较过程会更精细,但对复现来说,这个简化版本已经能跑出不错的效果。
下面是简化版的示意代码,方便理解核心流程:
import torch import torch.nn as nn import torch.nn.functional as F class SelfConsistencyModule(nn.Module): def __init__(self, in_dim, hidden_dim=128): super().__init__() self.local_encoder = nn.Sequential( nn.Conv2d(in_dim, hidden_dim, 1), nn.ReLU(inplace=True), nn.Conv2d(hidden_dim, hidden_dim, 1), ) self.comparator = nn.Sequential( nn.Conv2d(hidden_dim * 2, hidden_dim, 1), nn.ReLU(inplace=True), nn.Conv2d(hidden_dim, 1, 1), ) def forward(self, feature): local = self.local_encoder(feature) # B,C,H,W global_desc = local.mean(dim=(2, 3), keepdim=True) # B,C,1,1 global_desc = global_desc.expand_as(local) fused = torch.cat([local, global_desc], dim=1) score = torch.sigmoid(self.comparator(fused)) return score这段代码的关键在global_desc的构造。我一开始用它做实验,发现当拼接区域占画面很大比例时,全局特征会被篡改区域“污染”,导致误检。后来改成区域池化,取图像上半部分和下半部分各算一个全局描述,再分别比较,效果稳了不少。你在复现时不要盲目抄这个代码,最好根据你的任务特点调整全局描述符的聚合方式。
3.2 合成数据的每个环节都是超参数
这个项目最大的工程难点不是网络,而是合成拼接数据。真实世界有标注的拼接数据集太少,而且样本量撑不起深度学习训练,通常只能自己合成。合成流程我总结成五步:
- 随机选一张底图,再随机选一张素材图。
- 在素材图上随机切一个区域,区域形状可以是矩形,但更推荐随机多边形或不规则选区。
- 把切下来的区域贴到底图的随机位置,保证不与底图来源重复。
- 对拼接边界做羽化,或者用泊松融合做颜色混合。
- 全图随机做后处理:JPEG压缩、缩放、加高斯噪声、亮度抖动、对比度抖动。
每一步都有讲究。比如区域形状用随机多边形,能让模型学会处理不规则边界,而不是只盯着矩形缝。JPEG质量因子我一般随机取50到95,因为过低会让整张图产生统一块效应,反而消掉拼接痕迹;过高又让伪造痕迹太明显。实际操作里,我会把后处理组合当成一个随机策略,每个batch都不同,让网络适应“任何处理程度”。
3.3 掩膜标注的软化与加权
合成时生成的ground truth是一张二值mask,拼接区域为1,其余为0。但这个硬标签有个问题:拼接边界在做羽化和泊松融合之后,真实边界可能向外扩散或向内收缩了2到3个像素。如果强行用硬标签监督,网络会把大量精力花在拟合那条已经被融合算法弄模糊的边缘上,反而忽略大范围的一致性判断。
我的做法是先把mask做一次腐蚀和一次膨胀,得到边界带,然后在损失函数里把边界带的权重降到正常区域的0.2。这样做等于告诉网络:你把大块区域判对就行,边缘附近不用太较真。实际效果是收敛速度更快,最终预测的掩膜也不会出现那种“边缘亮中间暗”的撕裂感。
4. 复现实操:从零搭一个简易版
4.1 环境和工程文件组织
我用的环境是Python 3.10、PyTorch 2.x,显卡至少需要12G以上显存。工程目录我习惯分成四块:
configs/ # 训练参数配置文件 dataset/ # 数据生成和采样逻辑 models/ # 网络结构定义 utils/ # 评估指标、可视化、后处理工具不建议把数据生成代码和训练代码堆在同一个脚本里。合成数据这个模块会经常改,单独拎出来能让你调起来更顺手。
4.2 合成数据管线示例
下面是我常用的一段拼接样本生成逻辑,代码做了简化,重点展示坐标和mask的正确性:
import cv2 import numpy as np def random_splice(base_img, source_img, max_region=0.3): h, w = base_img.shape[:2] rh = int(h * np.random.uniform(0.1, max_region)) rw = int(w * np.random.uniform(0.1, max_region)) x1 = np.random.randint(0, w - rw) y1 = np.random.randint(0, h - rh) sx1 = np.random.randint(0, source_img.shape[1] - rw) sy1 = np.random.randint(0, source_img.shape[0] - rh) mask = np.zeros((h, w), dtype=np.uint8) mask[y1:y1+rh, x1:x1+rw] = 255 roi = source_img[sy1:sy1+rh, sx1:sx1+rw] composite = base_img.copy() blend_ratio = np.random.uniform(0.6, 1.0) composite[y1:y1+rh, x1:x1+rw] = cv2.addWeighted( base_img[y1:y1+rh, x1:x1+rw], 1 - blend_ratio, roi, blend_ratio, 0 ) # 随机后处理 if np.random.rand() < 0.8: quality = np.random.randint(50, 95) encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), quality] _, enc = cv2.imencode(".jpg", composite, encode_param) composite = cv2.imdecode(enc, 1) if np.random.rand() < 0.3: composite = cv2.GaussianBlur(composite, (3, 3), 0) return composite, mask这段代码我特别想提醒一个坑:roi的区域尺寸必须和目标区域尺寸完全一致,否则cv2.addWeighted会直接报错。很多人第一次跑数据管线时卡住,大多数都是矩形坐标加一减一引起的尺寸对不齐。如果用了随机多边形,还要用cv2.fillPoly生成mask,同样注意坐标类型。
4.3 模型与训练循环
模型我用了一个预训练分类网络作为骨干,只取倒数第二层和倒数第三层的特征图,再接自一致性模块。这样做的好处是预训练权重能提供稳定梯度,不用从零开始学底层纹理。训练拐点出现得很快,差不多两三个epoch就能看到mask雏形。
训练循环里我额外做了一件事:对全局描述符添加轻量的梯度反转。这也是从复现里得到的教训,网络如果太依赖全局平均特征,会变得保守,对大片区域输出很平均的分数。梯度反转能逼着局部特征去发现和全局不一致的地方,而不是躺平。
optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) criterion = nn.BCEWithLogitsLoss() for step, (img, mask) in enumerate(train_loader): img = img.cuda() mask = mask.cuda().float() pred = model(img) # 输出概率图,尺寸与mask一致 loss = criterion(pred, mask) optimizer.zero_grad() loss.backward() optimizer.step() if step % 200 == 0: iou = compute_iou((pred > 0.5).float(), mask) print(f"step {step} | loss {loss.item():.4f} | iou {iou:.4f}")训练时我用了比较小的batch size,8张图。因为多尺度会把每张图变成三份,实际每次前向要处理24张分辨率不同的图像,显存压力不小。优化器用Adam,初始学习率1e-4,训练到验证集指标不再上升时再降到1e-5。
4.4 验证指标的选取
评估拼接检测不能只看准确率。因为一张图里通常只有很小一块区域是拼接的,网络只要全预测成0,准确率也能到95%以上,但一点用都没有。我习惯同时看三个指标:
- 像素级IoU:预测mask和真值mask的交并比。
- 像素级F1:综合精确率和召回率。
- 图像级准确率:一张图里有拼接区域,且预测出的连通域与真值有重叠,才算检测成功。
如果IoU一直提升但F1上不去,说明预测范围偏大或偏小;如果图像级准确率很差,说明网络还没学会“定位一块区域”这件事。我通常用这三个指标一起看,单独哪一个都不能说明问题。
5. 常见问题与排查技巧实录
5.1 最让人头大的坐标错位问题
我第一次跑合成数据,模型训练了一整晚,loss降得挺漂亮,可视化发现预测mask跟拼接区域完全不重叠。查了两个小时,最后发现是生成mask时用的坐标是浮点数,直接赋给了np.zeros,浮点坐标被自动截断,导致mask整体偏移了一个像素。这种情况在小区域拼接上会被放大,预测结果偏出目标区域,训练就变成了看空气猜位置。
排查办法很简单:每个batch随机选一两张图,把mask用半透明红色叠加到原图上存下来,肉眼看一眼是否对齐。不要觉得可视化浪费时间,很多诡异问题都是这样一眼看出来的。
5.2 训练稳定性靠三个开关
图像拼接检测训练不太稳定,我自己总结出三个最关键的开关:
- 损失加权:正负样本比例严重失衡,给正样本更高权重,或者用Dice loss辅助。
- 数据均衡:每个batch尽量保证底图风格多样,不要连续几十个batch都是同一类场景。
- 学习率:不要一开始就上5e-4这种比较大的值,自监督分支很容易震荡,我用1e-4起步,等loss下降趋势稳定后再手动提高权重。
这三个开关我有一次同时调反了,训练直接崩掉:loss升到几十,预测图全变成灰色,后来只能重开。所以如果你复现时发现指标忽上忽下,先别怀疑网络结构,把上面三件事检查一遍。
5.3 小区域拼接检测不到
小拼接区域是这种方法的弱项。因为拼接区域越小,它携带的“不一致信息”越少,全局特征很容易把它当作正常噪声忽略掉。我试过几种策略,最有效的是三招并用:
- 输入图像先上采样2倍再进入网络,相当于把目标区域放大,特征图上的有效分辨率更高。
- 训练时多生成一些小区域的样本,例如把区域大小下限定到32×32像素。
- 损失函数换成Focal Loss,让网络关注难分类像素。
用了这三招之后,小拼接块的召回率大概能提升10个点以上。当然代价是训练时间变长,推理速度也受影响,需要自己取舍。
5.4 多尺度推理时显存不够
多尺度推理最省内存的方式是不要把所有尺度一次性喂给网络。我一开始图省事,把一个batch里的三张缩略图拼成大batch一起前向,结果部署到小显存机器上直接OOM。后来改成逐个尺度跑,再把三个概率图插值回原图大小取平均,内存占用立刻下来了。
def multi_scale_infer(model, img, scales=(0.5, 1.0, 1.5)): probs = [] for s in scales: scaled = F.interpolate(img, scale_factor=s, mode="bilinear", align_corners=False) with torch.no_grad(): p = model(scaled) p = F.interpolate(p, size=img.shape[-2:], mode="bilinear", align_corners=False) probs.append(p) return torch.mean(torch.stack(probs), dim=0)这段代码在测试时非常实用。要是你连单尺度都跑不动,就先减小输入分辨率,再逐步加尺度,别硬撑。
5.5 真实场景效果不如预期
合成数据和真实篡改之间一定有domain gap。我遇到过很多次:合成样本上IoU做到0.7,拿到真实拼接图上就掉到0.3。问题主要出在真实篡改者会做很多“不自然但掩盖得很好”的加工,比如使用局部颜色匹配、内容感知填充、多重保存。单纯靠随机后处理不一定能完全覆盖。
我的做法是在训练后期加入一个微调阶段:先用大量合成数据把模型训练到收敛,再用少量真实拼接样本微调几个epoch。这时候学习率要调低,避免破坏已经学好的自一致性特征。微调之后,真实样本的IoU能回升不少。
6. 自一致性思路还能用在哪
这个项目给我的最大收获,不是拿到一个能用的拼接检测模型,而是把“一致性”这个判断逻辑深深印在了脑子里。后来我转头做生成图像检测,发现Diffusion生成的图像也普遍存在局部和整体风格不一致的问题,只是表现形式更多样。我把自一致性分支加进检测模型后,对AI生成图片的识别能力也提升了不少。
现在做这一类任务,我更倾向于用Transformer把特征点之间的交互关系建模得更直接,让每个局部都主动去查询其他区域的信息,而不是靠池化做全局总结。这个方向从2018年到现在已经有了很多新玩法,但思想源头依然是“自己与自己比较”。
最后分享一个我自己的复现技巧:先用256×256的输入和粗掩膜把整个流水线跑通,再换大图和多尺度。这个项目的复杂度不在网络结构,而在无穷多的数据处理细节,先把流程跑通,你才分得清楚哪些问题来自模型,哪些问题来自数据。希望这份经验能让你少走一点弯路。