资讯详情

图像篡改检测实战:微调Faster R-CNN实现框级定位

📅 2026/9/18 16:56:50 | 华诺云谱 👁 阅读
图像篡改检测实战:微调Faster R-CNN实现框级定位
简介围绕深度学习图像篡改检测的实践文档聚焦Faster R-CNN在图像真实性与完整性认证中的应用适合具备一定PyTorch基础、希望将目标检测算法落地到内容取证场景的研究人员与中高级开发者。内容先梳理两条技术路线既可选择微调Faster R-CNN、RGB-N、BiseNet等小模型也可借助SAM、Grounded-SAM等大模型或多模型协同完成判别。正文以可运行的Python代码为主线给出自定义TamperedDataset数据集类加载图片并打标签、数据增强与张量预处理、替换FastRCNNPredictor分类头以适配篡改与未篡改二分类、配置SGD优化器与StepLR学习率调度、多轮迭代训练及权重保存等完整环节同时说明如何用专门的篡改测试集做精度评估并附上一段判断单张未知照片是否被改动的小程序片段还讨论了多模态协作的应用潜力与挑战。整包仅1个docx文档约21KB便于快速查阅与二次改写已有163人学习。1. 检测框而不是概率Faster R-CNN 在图像篡改检测里的真实定位有同学把一份 Splicing 类数据集丢给 ResNet 训二分类测试集准确率 0.96换到手机拍摄再加一次平台压缩的图准确率掉到 0.6。问题不在网络深度而在于把这张图有没有被改当成了唯一目标模型学到的其实是这张图的纹理像不像数据集里的伪造样本。图像篡改检测要回答的是两件事篡改属于哪一类、改在什么位置后者必须靠目标检测输出框框本身就是可核查的证据。用 Python 加 PyTorch 微调一个预训练 Faster R-CNN是这类任务性价比最高的起点。ResNet-50 加 FPN 的骨干已经学过边缘、纹理、光照一致性这些底层特征RPN 负责提出候选区域RoI 头负责判定候选区是背景还是篡改两阶段结构天然贴合先找可疑区、再定性的取证流程。适合有目标检测基础、需要交一份可复现实验的开发者也适合做图像真实性认证、想把像素级掩码升级成框级结论的人。SAM、Grounded-SAM 这类大模型更适合当标注工具不适合直接当第一版检测器。2. 数据管线从像素级掩码到检测器需要的框标注一个能跑通但没用的 Faster R-CNN 训练脚本八成死在数据这一层。这一章把掩码转框、Dataset 约定、collate_fn、增强策略四件事按顺序拆开每一步都会给出可复制的实现。2.1 整图当框会让 RoI 头退化成分类头很多示例代码把boxes直接写成[[0, 0, W, H]]标签由文件名里有没有tampered决定。这种写法损失能降loss 曲线也好看但 RPN 回归分支几乎拿不到有效梯度——所有正样本框都是同一张整图回归目标和提议框的偏移量永远是同一个方向。推理时输出的框会贴近全图框失去信息量等价于只用了分类置信度。合格的框标注有两个来源像素级掩码转框或者人工框选。常见的公开数据多以二值掩码形式发布需要自己转成xyxy。转框时有一个容易忽略的点一张图里可能同时存在两处互不相连的篡改区域直接取外接矩形会把大片未篡改区域包进去让 RoI 头学到错误的纹理统计。正确做法是先做连通域分析每个连通域出一个框。2.2 mask 转 boxes连通域拆分与最小面积过滤import numpy as np from PIL import Image from scipy import ndimage def mask_to_boxes(mask_path, min_area64, dilate2): 把二值篡改掩码转成 xyxy 框列表返回 None 表示无篡改 mask np.array(Image.open(mask_path).convert(L)) binary (mask 127).astype(np.uint8) if binary.sum() 0: return None # 适度膨胀把边缘零散像素并回主连通域 binary ndimage.binary_dilation(binary, iterationsdilate).astype(np.uint8) labeled, num ndimage.label(binary) boxes [] for i in range(1, num 1): ys, xs np.where(labeled i) if len(xs) min_area: # 小于 64 像素的多半是掩码噪声 continue boxes.append([float(xs.min()), float(ys.min()), float(xs.max()), float(ys.max())]) return boxes or Nonemin_area卡的是连通域像素数而不是框面积能过滤掉边缘锯齿产生的碎块dilate用两到三次膨胀把弱边缘连起来否则同一处篡改会被拆成十几个小框训练时正样本数量虚高。返回值用None而不是空列表是为了在 Dataset 里明确区分无篡改和转换失败两种状态。2.3 Dataset 与 collate_fn 里必须对齐的三个约定import os, torch from PIL import Image from torch.utils.data import Dataset from torchvision.transforms import functional as F class TamperDataset(Dataset): def __init__(self, img_dir, mask_dir): self.img_dir, self.mask_dir img_dir, mask_dir self.names [n for n in os.listdir(img_dir) if n.lower().endswith((.jpg, .png, .jpeg))] def __len__(self): return len(self.names) def __getitem__(self, idx): name self.names[idx] img F.to_tensor(Image.open(os.path.join(self.img_dir, name)).convert(RGB)) mask_path os.path.join(self.mask_dir, os.path.splitext(name)[0] .png) boxes mask_to_boxes(mask_path) if os.path.exists(mask_path) else None if boxes is None: # 无篡改空框 空标签 target {boxes: torch.zeros((0, 4), dtypetorch.float32), labels: torch.zeros((0,), dtypetorch.int64)} else: target {boxes: torch.tensor(boxes, dtypetorch.float32), labels: torch.ones((len(boxes),), dtypetorch.int64)} return img, target def collate_fn(batch): return tuple(zip(*batch)) # 保持 list[Tensor] / list[dict]不能 stack三个约定F.to_tensor把图像压到 0-1 的 float CHW不要再手动除均值方差fasterrcnn_resnet50_fpn内部的GeneralizedRCNNTransform会按 ImageNet 统计量归一化一次重复归一化会让输入分布偏掉boxes必须是xyxy且float32写成 COCO 的xywh不会报错但框会整体错位labels必须是int64且从 1 开始0 被背景类占用Mask R-CNN 之后的版本都不会替你纠正这个约定。collate_fn必须自定义默认实现会尝试把长度不一的targets堆成张量并直接抛错。2.4 哪些数据增强会把篡改痕迹抹掉篡改检测的增强比常规检测更保守因为很多增强操作本身就会引入新的伪造痕迹或者把原有痕迹磨平。增强操作建议原因水平翻转 同步翻转框推荐不改变噪声分布翻转框后标注仍精确随机裁剪 同步裁剪框谨慎可能把篡改区域裁掉一半需过滤剩余面积轻度 JPEG 压缩质量 70-90推荐模拟真实传播链路提升跨平台鲁棒性强色偏 / ColorJitter不推荐全局色调变化会盖过拼接处的色温不一致高斯模糊、降采样不推荐直接破坏高频残差而残差是主要判据Cutout / 随机遮挡不推荐遮挡块可能正好压住篡改区域标签变错如果方案里用到了噪声残差分支压缩质量不要低于 70低于这个阈值时残差图基本被量化噪声淹没模型会转而依赖颜色不连续这类弱线索换数据集就失效。3. 微调 Faster R-CNN分类头替换、损失构成与训练循环数据管线通了之后模型侧真正需要改的只有分类头、学习率分组和训练循环写法这三处。剩下的工作基本都是围绕显存和收敛稳定性做取舍。3.1 分类头替换与num_classes的取值import torch from torchvision.models.detection import fasterrcnn_resnet50_fpn, FasterRCNN_ResNet50_FPN_Weights from torchvision.models.detection.faster_rcnn import FastRCNNPredictor def build_model(num_classes2, min_size800, max_size1333): model fasterrcnn_resnet50_fpn( weightsFasterRCNN_ResNet50_FPN_Weights.COCO_V1, # 新接口别再用 pretrainedTrue min_sizemin_size, max_sizemax_size) in_features model.roi_heads.box_predictor.cls_score.in_features model.roi_heads.box_predictor FastRCNNPredictor(in_features, num_classes) # 推理阶段的输出阈值训练阶段不生效 model.roi_heads.score_thresh 0.05 model.roi_heads.nms_thresh 0.5 model.roi_heads.detections_per_img 100 return modelnum_classes2指的是背景 篡改两类不是篡改 未篡改。如果把未篡改图也当成一类正样本送进labelsRoI 头会同时学习有篡改和没篡改两个前景类但它们共享同一套框回归目标最终两边都学不干净。正确做法是无篡改图返回空框空标签只参与 RPN 的负样本采样。FastRCNNPredictor建议从torchvision.models.detection.faster_rcnn显式导入写成torchvision.models.detection.faster_rcnn.FastRCNNPredictor依赖子模块已被隐式加载在不同 torchvision 版本上表现不一致。weights参数是 0.13 之后的新接口与旧版pretrainedTrue二选一同时写会在启动时报参数冲突。3.2 六项损失分别暴露什么信息Faster R-CNN 在model.train()下返回一个字典共六项loss_objectness、loss_rpn_box_reg来自 RPNloss_classifier、loss_box_reg来自 RoI 头启用 mask 分支后还会多两项。把这六项分开打印比只看总和有效得多。loss_objectness长期不降说明 RPN 的正负样本比例失衡通常是篡改区域太小或者锚框尺度和目标尺寸不匹配loss_rpn_box_reg高但loss_classifier正常说明候选框位置偏但分类对属于回归头欠拟合可以单独把回归分支的学习率调大loss_box_reg持续在 0 附近而loss_classifier很高往往是无篡改图占比过大正样本几乎没被采到。这些判断只有拆开打印才能做出来。3.3 分组学习率与训练循环实现model build_model().to(device) backbone, head [], [] for name, p in model.named_parameters(): if not p.requires_grad: continue (backbone if name.startswith(backbone) else head).append(p) optimizer torch.optim.SGD([ {params: backbone, lr: 5e-4}, # 预训练权重小学习率防遗忘 {params: head, lr: 5e-3}, # 新初始化的头大学习率快速收敛 ], momentum0.9, weight_decay5e-4) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size3, gamma0.1) for epoch in range(num_epochs): model.train() stat {} for images, targets in train_loader: images [im.to(device) for im in images] targets [{k: v.to(device) for k, v in t.items()} for t in targets] loss_dict model(images, targets) # train 模式只返回 loss不返回预测 losses sum(loss_dict.values()) optimizer.zero_grad() losses.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm10.0) optimizer.step() for k, v in loss_dict.items(): stat[k] stat.get(k, 0.0) v.item() scheduler.step() print(epoch 1, {k: round(v / len(train_loader), 4) for k, v in stat.items()})分组学习率的依据是参数来源骨干是 COCO 预训练的改动过猛会破坏底层特征box_predictor是随机初始化的学习率太小会长时间卡在高损失平台。clip_grad_norm_在 batch 只有 1 到 2 张图时几乎是必需的RPN 的回归损失在早期容易出尖峰。另外model.train()与model.eval()的差别不只是 BN 和 DropoutFaster R-CNN 在 train 模式下不会返回预测结果调output[boxes]会直接KeyError训练中途想验证必须切到eval()并套torch.inference_mode()。3.4 显存压不住时的三个降级手段手段参数修改代价降低输入分辨率min_size600, max_size1000小目标召回下降微小篡改区可能漏检冻结骨干浅层for p in model.backbone.body.layer1.parameters(): p.requires_gradFalse收敛变快跨域泛化略降减少 RPN 提议数rpn_pre_nms_top_n_train1000训练速度提升召回上限降低三种手段可以叠加但不要同时把min_size和提议数压到很低那样 RPN 的召回会成为整条链路的瓶颈怎么调学习率都补不回来。4. 评估与预测IoU 匹配、置信度阈值与三个高频错误训练日志好看和模型可用之间隔着一整套评估。篡改检测的评估比常规检测更容易写错因为它天然存在一张图零个框的样本。4.1accuracy_score为什么在这里必然是错的把预测标签和真实标签用all_preds.extend(preds)、all_labels.extend(labels)拉平之后再算准确率会遇到两个问题。一是长度不对齐一张图可能有 3 个真实篡改框模型只输出 1 个预测框阈值过滤后两个列表长度不同accuracy_score直接抛ValueError。二是即便长度恰好相等逐位置比较也毫无意义第 1 个预测框和第 1 个真实框未必对应同一处篡改。检测任务的正确做法是按图做 IoU 匹配再统计真正例、假正例、假负例最后算精确率、召回率和 F1。4.2 按图做 IoU 贪心匹配的评估实现import torch from torchvision.ops import box_iou torch.inference_mode() def evaluate(model, loader, device, score_thr0.5, iou_thr0.5): model.eval() tp fp fn 0 for images, targets in loader: images [im.to(device) for im in images] outputs model(images) # eval 模式才返回预测 for out, tgt in zip(outputs, targets): keep out[scores] score_thr pb, pl out[boxes][keep].cpu(), out[labels][keep].cpu() gb, gl tgt[boxes], tgt[labels] if len(gb) 0: # 无篡改图任何预测都是误报 fp len(pb) continue if len(pb) 0: fn len(gb) continue iou box_iou(pb, gb) # [P, G] matched_gt set() for i in range(len(pb)): j int(torch.argmax(iou[i])) if iou[i, j] iou_thr and pl[i] gl[j] and j not in matched_gt: matched_gt.add(j) tp 1 else: fp 1 fn len(gb) - len(matched_gt) precision tp / max(tp fp, 1) recall tp / max(tp fn, 1) f1 2 * precision * recall / max(precision recall, 1e-6) return {precision: precision, recall: recall, f1: f1}iou_thr取 0.5 是检测领域的通行口径论文里也常见 0.5:0.95 的平均值。score_thr只影响评估时的预测集合与模型内部的roi_heads.score_thresh是两回事后者决定前向传播里保留多少框前者决定你统计时信任多少。贪心匹配用matched_gt集合防止同一个真实框被多个预测框重复算作正例这是很多手写评估脚本漏掉的一步。如果项目需要和像素级方案对比再加一组像素 IoU 和图像级 AUC。图像级 AUC 的构造方式是把每张图所有预测框的最高分当作该图的篡改得分再和图像级标签算 AUC阈值调参时比 F1 更稳定。4.3 单图预测阈值要分两处调torch.inference_mode() def predict_image(path, model, device, score_thr0.6): model.eval() img F.to_tensor(Image.open(path).convert(RGB)).to(device) out model([img])[0] keep out[scores] score_thr boxes out[boxes][keep].cpu().numpy().round(1).tolist() scores out[scores][keep].cpu().numpy().round(3).tolist() return (Tampered, list(zip(boxes, scores))) if boxes else (Not Tampered, [])model.roi_heads.score_thresh默认 0.05作用是先滤掉大量低分框再进 NMS设太高会让召回在源头就损失掉。实际调参用的是predict_image里的score_thr落地点在 0.5 到 0.7 之间低于 0.5 误报明显上升高于 0.7 会漏掉边缘模糊的小面积拼接。注意F.to_tensor后面不要加.unsqueeze(0)再走一遍归一化模型内部会处理 batch 维和标准化。5. 从单模型到多模型协同伪标注、双流输入与推理提速单模型跑通之后提升空间基本集中在标注质量和输入表征两处而不是继续换骨干。5.1 用大模型做伪标注再蒸馏回 Faster R-CNNSAM、Grounded-SAM 这类可提示分割模型在篡改区域的边界提取上比人眼快但推理成本高、对篡改类型没有判别力。可行的流水线是用 Grounded-SAM 在候选图上生成掩码转成框后用第 2 章的mask_to_boxes落到xyxy人工只做二次筛选把确认后的框喂给 Faster R-CNN 训练。这样标注成本从逐像素降到逐框而检测器保留毫秒级推理速度。伪标注的坑在于分割模型会把高对比度区域一并切出来比如纯色背景上的水印需要在筛选阶段按面积占比和长宽比做一次过滤。5.2 加一路噪声残差把conv1改成 4 通道拼接边缘的噪声分布与周围存在差异RGB 三通道对这类线索不敏感加一路高通残差能明显提升小面积拼接的召回。做法是在 Dataset 里生成残差通道并在通道维拼接同时把骨干第一层卷积改成 4 通道输入。import torch, torch.nn.functional as F def high_pass_residual(x): x: [C,H,W] float返回同样尺寸的高频残差作为第 4 通道 k torch.tensor([[0, -1, 0], [-1, 4, -1], [0, -1, 0]], dtypetorch.float32) k k.view(1, 1, 3, 3).repeat(3, 1, 1, 1).to(x.device) / 4.0 return F.conv2d(x.unsqueeze(0), k, padding1, groups3).squeeze(0).abs() def expand_conv1(model): w model.backbone.body.conv1.weight.data # [64,3,7,7] new_conv torch.nn.Conv2d(4, w.shape[0], 7, stride2, padding3, biasFalse) new_conv.weight.data[:, :3] w new_conv.weight.data[:, 3] w.mean(dim1) # 新通道用原通道均值初始化 model.backbone.body.conv1 new_conv return model用均值初始化第 4 通道而不是随机初始化是为了让预训练特征在前几个 epoch 不被破坏。改成 4 通道后GeneralizedRCNNTransform仍按 3 通道均值方差做标准化需要把model.transform换成自定义归一化4 通道的 mean/std 把第 4 通道补 0 和 1否则残差通道会被错误缩放。RGB-N 这类双流结构走的是更重的路线两条分支各带一个骨干最后在 RoI 特征上做融合精度更高但显存翻倍适合有 A100 级别的环境。5.3 推理侧提速与 TTA 的取舍部署侧最先动的三个参数是min_size、max_size和半精度。把min_size从 800 降到 600、开启torch.autocast(cuda, dtypetorch.float16)单张图推理通常能压到原来的三分之一代价是小目标召回下降三到五个点需要用自己的验证集确认能否接受。detections_per_img从 100 降到 20 也能省一部分 NMS 时间前提是确认单图篡改区域数量不会超过 20。TTA 的方向要选对水平翻转加框合并再跑一次 NMS能提升一到两个点 F1成本是推理翻倍多尺度测试600/800/1000 三档收益更大但延迟是线性增长。我的做法是只在离线复核链路开 TTA在线服务保持单尺度加 FP16把复核结果回灌成训练数据用数据侧的收益替代推理侧的算力开销。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。