简介:基于Transformer的遥感影像变化检测算法项目源码,面向遥感影像分析与变化检测方向的研究者、算法工程师及高年级研究生,针对传统手工特征方法难以捕捉复杂地表动态的痛点,给出一个覆盖数据准备、模型设计、训练评估全流程的可直接运行方案。压缩包共61个文件,以20个Python脚本和1个PyTorch权重文件为核心,配合2个Shell一键运行脚本、Markdown/TXT说明文档以及36张PNG结果示意图,整体约为57.55MB,结构上按代码、模型、数据、脚本、图片等模块区分,便于按需查阅。目前已有225人学习下载,适合需要快速搭建变化检测基线、复现Transformer在该任务上效果的用户。源码具体涵盖遥感影像裁剪与归一化、通道/尺寸适配的数据集构造,基于ResNet等骨干的Transformer网络搭建,损失函数与评估指标实现,以及训练器、日志处理和演示推理等环节;同时附带列表型样例数据(如B/A变化对与标签)和可视化输出,使用者既可基于自带说明跑通流程,又能针对具体场景调整网络结构、训练参数或数据路径,便于二次开发与实验对比。
1. 变化检测和Transformer:两期影像找差异,这个任务为什么非它不可
手里有两期同一区域的遥感影像,一期是去年,一期是今年,目标是自动把新建的房屋、改道的河流、新修的公路全部像素级地标出来。这就是遥感影像变化检测。传统做法靠人工目视解译,费眼且效率低;用CNN做,又常常在“两期影像之间对比”这个关键环节上力不从心。而Transformer天然长于捕捉长距离依赖,做双时相影像的全局对比优势明显,近两年基于Transformer的变化检测算法在公开数据集上几乎全面压过了纯CNN方案,也成为遥感项目实战里最值得复现的方向之一。
这篇笔记面向两类人:想把Transformer落地到遥感变化检测、需要源码级参考的工程师,以及拿“基于Transformer的遥感影像变化检测”做项目实战和毕设、需要完整技术路径的学生。我会按“任务建模 → 网络结构 → 数据与训练 → 踩坑 → 进阶调参”的顺序,把一个可运行方案的关键环节讲透,所有命令和配置都以常见开源实现为基准,你拿到的源码包基本能按这个思路跑通。
2. 任务拆解:双时刻影像的像素级变化到底在检测什么
2.1 变化检测不是“两图相减”,是“语义级别的差异判断
很多人第一次接触变化检测,直觉反应是:两期影像做像素差,差异大的地方就是变化区。这种思路在严格配准、无光照差异、无季节差异的理想数据上能看个大概,但真实遥感数据里,同一地点两期影像的辐射差异往往远大于真实变化的像素差异。比如农田在不同季节植被颜色完全不同,水体随雨旱两季面积自然波动,这类伪变化会被差值法全部识别为变化区域,结果几乎不可用。
实际工程里,变化检测被定义为“语义变化检测”:模型必须理解两期影像里每个像素分别属于什么地物,再判断同一位置的语义类别是否发生了改变。前后都是耕地、只是颜色不同,不算变化;前一期是耕地、后一期变成建设用地,才算变化。这个定义直接决定了网络结构的设计——模型不能只做低层特征的距离度量,必须在高层语义特征空间里做对比,这也是后面选择Transformer结构的总前提。
2.2 CNN的局限:局部感受野撑不起全局比对
纯CNN做图像分割已经非常成熟,U-Net、DeepLabV3+在单时相语义分割任务上是主力。但变化检测和单图分割最大的区别在于:判断一个像素是否变化,不仅要看它自己周围的信息,还要看它在前一时刻的对应位置和周边上下文。CNN靠堆叠卷积层扩大感受野,理论上一张256x256的图经过足够深的网络也能覆盖全图,但实际训练中,远距离信息的传播要经过很多层,梯度衰减和特征稀释让有效感受野远小于理论值。结果是CNN对“局部外观差异”敏感,对“全局结构变化”迟钝。
举一个在LEVIR-CD这类建筑变化数据集上常见的失败案例:同一栋楼被树木部分遮挡,两期影像中楼顶可见区域发生变化,CNN可能只标出可见部分的边缘;而Transformer的自注意力机制在第一步就把每个像素和全图所有位置建立连接,楼顶被遮挡后,仍能通过周围道路、相邻建筑的上下文推断出楼体轮廓,变化区域标注更完整。这不是玄学,是注意力机制的结构性优势。
2.3 双时刻输入的两种建模范式:早融合和双分支
把两期影像喂给模型,工程上有两种主流方案。
第一种是早融合(Early Fusion),也叫通道拼接:把前后两期RGB影像在通道维度拼起来,形成一个6通道输入,直接送入编码器。CNN backbone的第一层卷积要相应调整输入通道数。这种方案结构最简单,两期影像在最底层就开始交互,适合两期影像已经严格配准、且辐射差异不大的数据。
第二种是双分支共享权重(Siamese结构):两期影像分别经过同一个编码器,得到两组特征图,再在特征层面做差异计算或融合。常见做法是两组特征做差值或拼接后送入解码器。Siamese结构的好处是强制模型用同一套语义提取规则去理解两期影像,减少因模型内部表示不一致引入的伪变化。早期基于CNN的FC-EF、FC-Siam-Concate等经典模型用的就是这两种范式,基于Transformer的实现同样继承了这两条路线——直接把两期patch序列拼接送入encoder,或者用共享权重的两个encoder分别提取特征后在解码器融合。
2.4 为什么Transformer成了变化检测的主流选择
Transformer最早是机器翻译模型,自带了两个对变化检测极度友好的特性:全局感受野和动态权重。全局感受野前面说过,每个token都能直接看到所有其他token,意味着后一时相影像中的任意位置,可以直接“关注”到前一时相全图的信息,这个对比能力是CNN难以企及的。
动态权重同样关键。CNN的卷积核一旦训练完成就是固定的,对“窗户被替换”和“房屋被拆除”这两种尺度差异巨大的变化,共享同一套局部算子。而Transformer的注意力权重是随输入动态计算的,大范围变化会把注意力分配到更大空间范围,小范围变化则聚焦到局部,这种自适应行为非常契合变化检测中变化尺度不固定的特点。当前热门的开放词汇变化检测、基于Swin Transformer的遥感影像变化检测、以及各种“双时相Transformer”变体,本质上都是在这个能力基础上做文章。
3. 模型结构拆解:从patch序列到变化掩码,Transformer各部件怎么搭
3.1 整体Pipeline:一个可移植的五段式结构
基于Transformer的遥感影像变化检测模型,无论具体叫什么名字,绝大多数实现都遵循同一个五段式骨架,这也是你打开源码包后最需要先辨认的部分:
- Patch Embedding:把输入影像切块并投影为向量序列。
- 位置编码:为每个patch注入空间位置信息。
- Transformer Encoder:堆叠自注意力层,做全局特征交互。
- 特征金字塔/解码器:把高层语义特征恢复到原始分辨率。
- 分割头:逐像素二分类,输出变化概率图。
对于双时相输入,第1步之前需先完成双时相融合(通道拼接或Siamese双分支),后面我会按最常见的“双分支Siamese + 共享权重”结构展开,因为它在开源项目里出现频率最高、也最容易迁移到其他数据集上。
3.2 Patch Embedding和位置编码:输入侧的两个关键细节
ViT风格的Patch Embedding用卷积实现:假设输入单时相影像尺寸为H×W×3,设置一个卷积核大小为P、步长为P的二维卷积,输出通道数为Embedding维度D,得到(H/P)×(W/P)个patch token。对双时相Siamese结构,两期影像分别过同一个卷积层,各自得到一组token序列。紧接着把两组token拼接成长度为2×(H/P)×(W/P)的序列,供后续Encoder处理。
位置编码这里要注意一个工程细节:两期影像的token属于“同一地理位置在不同时刻的表示”,它们的空间位置含义完全一样。所以位置编码必须对两期token共享同一套位置索引,而不是给第二期另编一套。常见实现是生成一个长度为(H/P)×(W/P)的位置编码表,前后两期token都查同一张表。如果实现不当,把两期token当作一个连续序列编了递增位置,模型会学到错误的空间对应关系,训练时损失能下降,推理时变化边界会明显错位。
3.3 Transformer Encoder:标准多头注意力加上前馈网络
Encoder部分和NLP里的Transformer几乎一致,每层由多头自注意力(MSA)、LayerNorm、多层感知机(MLP)组成,MLP通常是两个线性层加GELU激活,中间维度是Embedding维度的4倍。不同开源实现里,Encoder堆叠层数从4层到12层不等,头数大多为8,Embedding维度常见是C=256或C=512。
自注意力计算时,Q、K、V全部来自同一组token序列。由于两期token已经拼接,每个位置既可以关注自身时刻的全局上下文,也可以关注另一时刻的对应区域和周边区域——这正是变化检测需要的跨时相交互。值得注意的是一些实现会额外引入“交叉注意力”结构,让后一时相的token专门attend前一时相的token,这种设计的意图是让“找差异”的任务更加定向,收敛速度通常比单纯拼接token更快,但原理和标准自注意力一致,理解了后者就能看懂前者。
3.4 解码器和分割头:把序列拉回像素空间
Encoder输出的是序列形式,要得到逐像素的变化掩码,必须做序列到图像的还原。常见实现有两种:
第一种是纯Transformer路线,把Encoder输出的token序列重新reshape成(H/P)×(W/P)×C的特征图,再用转置卷积(反卷积)或PixelShuffle层层上采样,恢复到H×W,最后接1×1卷积输出2通道变化概率图。第二种是混合路线,也是目前性能更好的做法:将Encoder不同层的输出整理成多尺度特征,类似FPN,然后交给U-Net风格的CNN解码器逐级融合。这类结构在遥感分割项目里出镜率很高,因为CNN解码器对边缘细节的恢复比纯上采样扎实。
分割头部分,输出通道数为2(变化/不变),后面接Softmax或Sigmoid得到每个像素的变化概率。训练阶段用交叉熵或Dice Loss约束;推理阶段对概率图取阈值(通常0.5)得到最终的二值变化图。
3.5 一个可参考的模型配置表
下面是一份我在该任务上常用的模型配置,显存约8-11GB,供你在改代码前有个基准:
| 配置项 | 设定值 | 说明 |
|---|---|---|
| 输入尺寸 | 256×256 | 单时相RGB,双时相拼接后为6通道或Siamese双分支 |
| Patch大小 | 16 | 每个patch覆盖16×16像素 |
| Embedding维度 | 256 | 太大显存压力高,太小表达力不足 |
| Encoder层数 | 8 | LEVIR-CD等中等规模数据集8层足够 |
| 注意力头数 | 8 | 与256维embedding匹配 |
| MLP倍率 | 4 | MLP中间维度=embedding×4 |
| 解码器 | FPN + U-Net式卷积解码 | 上采样用转置卷积,步长2,通道逐级减半 |
| 损失函数 | BCE + Dice(1:1加权) | 针对变化区占比小的数据集中问题 |
| 优化器 | AdamW | lr=1e-4,weight_decay=1e-4 |
这份配置在常见公开数据集上,F1分数大约在0.85-0.90区间(受数据集和训练时长影响),比同量级CNN方案高2-5个百分点,且对伪变化的抑制能力明显更强。如果你手里的源码包给出的默认配置和我这里不一致,多半是作者针对特定数据集做了微调,改回这份基准再训练往往更不容易翻车。
4. 数据准备与训练配置:最小可跑通命令与必调参数
4.1 数据集目录结构:先把数据摆放规范
无论你手上的源码包来自哪里,最常见的数据组织方式是“双时相影像对+同名标签图”。训练前先把数据集按下面的目录结构整理好,能避免大量路径修改的麻烦。假设数据放在项目根目录下的data/里:
data/ ├── train/ │ ├── A/ # 第一期影像,文件名如 001.png │ ├── B/ # 第二期影像,与A同名对应 │ └── label/ # 变化标签,与A/B同名,0为不变,1为变化 ├── val/ │ ├── A/ │ ├── B/ │ └── label/ └── test/ ├── A/ └── B/这里的重点是三目录同名对齐,并且标签必须是单通道灰度图、像素值为0和1(部分数据集存成0和255,需要转成0和1,否则损失函数计算会出问题)。整理完目录后跑一个快速校验脚本:
import os from PIL import Image def check_dataset(split): # split: 'train' / 'val' / 'test' base = f"data/{split}" # 校验文件是否一一对应 files_a = sorted(os.listdir(f"{base}/A")) files_b = sorted(os.listdir(f"{base}/B")) labels = sorted(os.listdir(f"{base}/label")) if os.path.exists(f"{base}/label") else [] assert len(files_a) == len(files_b), "前后期影像数量不一致" for a, b in zip(files_a, files_b): assert a == b, f"前后期文件名不对齐: {a} vs {b}" # 抽样检查标签是否有变化区 if labels: im = Image.open(f"{base}/label/{labels[0]}") print(f"{split} 标签模式: {im.mode}, 尺寸: {im.size}") print(f"标签像素值分布: {im.getcolors()}") check_dataset("train") check_dataset("val") check_dataset("test")这段脚本做三件事:检查前后期文件是否同名对应、查看标签的存储模式和尺寸、输出像素值分布确认是否已转为0/1。逻辑很简单,但能拦下数据层面的大部分低级错误——我见过不少项目训练好几轮才发现标签全是空白或者位于不匹配路径,浪费时间不说还容易让人误判模型结构有问题。
4.2 核心训练脚本:一个最小可运行骨架
整理好数据后,训练脚本的核心部分围绕“Dataset类定义 → 模型实例化 → 损失函数与优化器 → 训练循环”四步展开。下面是一个精简但可运行的最小骨架,基于PyTorch:
import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader from PIL import Image import numpy as np from torchvision import transforms # 1) Dataset: 按索引加载当期/前期影像和标签 class ChangeDetectionDataset(Dataset): def __init__(self, root, split, size=256): self.root = root self.split = split self.files = sorted(os.listdir(f"{root}/{split}/A")) self.size = size def __getitem__(self, idx): name = self.files[idx] img_a = Image.open(f"{self.root}/{self.split}/A/{name}").convert("RGB") img_b = Image.open(f"{self.root}/{self.split}/B/{name}").convert("RGB") label = Image.open(f"{self.root}/{self.split}/label/{name}").convert("L") # 注意: 训练要保证A/B经历完全相同的随机几何增强 transform = transforms.Compose([ transforms.Resize((self.size, self.size)), transforms.RandomHorizontalFlip(p=0.5), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) # A/B共用同一随机种子, 才能保持空间对齐 seed = torch.randint(0, 2**32, ()).item() torch.manual_seed(seed) img_a = transform(img_a) torch.manual_seed(seed) # 重设种子让B做相同变换 img_b = transform(img_b) label = torch.from_numpy(np.array(label.resize((self.size, self.size)))) / 255.0 label = (label > 0.5).long() return img_a, img_b, label, name def __len__(self): return len(self.files) # 2) 损失函数: BCE + Dice 混合 def bce_dice_loss(pred, target): bce = nn.functional.binary_cross_entropy_with_logits(pred, target.float()) pred_prob = torch.sigmoid(pred) # 拉平后计算Dice pred_flat = pred_prob.view(pred_prob.size(0), -1) target_flat = target.view(target.size(0), -1) dice = 1 - (2.0 * (pred_flat * target_flat).sum(1) + 1.0) / (pred_flat.sum(1) + target_flat.sum(1) + 1.0) return bce + dice.mean() # 3) 模型实例化 (以开源Transformer模型为例) # from model import build_model # 假设源码包提供此接口 # model = build_model(in_channels=3, num_classes=2, embed_dim=256, depth=8) # 4) 训练循环主流程 # optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) # loader = DataLoader(train_ds, batch_size=8, shuffle=True, num_workers=4) # for epoch in range(epochs): # for img_a, img_b, label, _ in loader: # pred = model(img_a, img_b) # 前向, 输出 (B,2,H,W) 或 (B,1,H,W) # loss = bce_dice_loss(pred, label.unsqueeze(1)) # optimizer.zero_grad() # loss.backward() # optimizer.step()这里必须强调Dataset里那段“共用随机种子”的写法。变化检测的输入是一对配准影像,如果A和B用了不同的随机翻转或裁剪,相当于人为破坏了配准,模型会学到“伪变化”,而且这种错误非常隐蔽——训练集loss可能正常下降,验证集指标会一直上不去。源码包如果不带这个细节,你在实践时一定得自己补上。
4.3 损失函数选择:为什么不能用纯BCE硬扛
变化检测任务最典型的难题是类别极度不平衡:以常见建筑变化数据集为例,变化像素往往只占全图面积的1%到5%,不变像素占绝对多数。直接训练BCE,模型很快学会“全部预测为不变”,loss数值看起来不高,但变化区域全部漏检,F1接近0。
BCE与Dice混合的损失是目前最常见的解法。BCE是逐像素独立计算,梯度信号密集且稳定;Dice按整体重叠度计算,天然对前景占比不敏感,即使变化区只占1%,也能提供有效梯度。两者相加后,BCE负责像素级精度,Dice负责区域级召回,达到平衡。如果模型依然偏向“全预测为不变”,可以适当调高Dice权重,或者换成Focal Loss,它的调制因子会让模型专注在难分样本上。实际项目中我在变化占比低于1%的数据集上,会直接选用Focal Loss加Dice的组合。
4.4 优化器和学习率配置:默认值可以这样定
优化器选AdamW是当前Transformer训练的主流做法,学习率初始值设为1e-4,weight decay为1e-2到1e-4之间。Transformer的训练对学习率比较敏感,过高会导致注意力权重震荡、loss不收敛,过低则收敛过慢。如果loss在训练早期出现异常跳变,先检查学习率是否超过3e-4,超过就降回来。
批次大小方面,256×256输入在8GB显存下双时相batch size建议为4到8。如果你的GPU只有6GB显存,把输入尺寸降为192×192或224×224,比强行减小batch更划算,因为分辨率对变化检测精度的影响远大于batch size在8以下时的差异。另外,建议加一个warmup阶段——前5个epoch学习率从1e-5线性升到1e-4,这个细节在NLP Transformer训练中几乎是标配,在遥感变化检测上同样效果明显。
4.5 训练启动与日志观察:跑起来后看什么
训练启动命令很简单,以项目根目录的train.py为例:
python train.py --data_dir data/ --batch_size 8 --epochs 60 \ --lr 1e-4 --embed_dim 256 --depth 8 --gpu 0跑起来之后,重点观察四个指标的变化趋势。第一是训练loss的下降速度,正常第一个epoch结束loss应该有明显下降;第二是验证集F1或IoU,应该在5个epoch后开始突破0.5并持续上升;第三是变化区域的召回率,如果召回率长期低于0.3,检查是不是类别不平衡或者配准问题;第四是预测图的视觉效果,每10个epoch存一张验证集预测图,只看loss曲线很难发现问题,一眼看预测图往往就能发现边界错位、碎斑过多、伪变化成片等具体毛病。
5. 避坑清单:源码跑不通和效果差背后的六个常见原因
5.1 现象:训练正常但验证集F1长期在0.3以下
原因排查次序我一般是这样:首先看两期影像是否做了相同的在线增强,前面已经说过,这是最容易犯的错误;其次看标签里变化像素的真实占比,如果整个训练集的label统计出来变化区不足0.5%,先考虑换损失函数,或者做裁剪让变化区比例提高;最后检查Backbone的预训练权重是否加载成功,不少Transformer主干在ImageNet上预训练过,加载预训练能显著加快收敛,忘了加载的话前期特征提取能力很弱,变化区域也识别不出来。解决方式:增强统一随机种子、统计标签分布、加上预训练权重的加载逻辑。
5.2 现象:预测图上变化区域边界一圈“亮边”
边缘处标出一圈变化像素,实际并不是真的变化,这是什么原因。常见原因是两期影像没有做严格的几何配准,存在亚像素级别的偏移,导致建筑边缘在前后两期图中位置错开了几个像素。模型学到的是“边缘错位=变化”。解决方式:数据集先用ENVI或ArcGIS做配准预处理;如果是自己下载的双时相影像,至少保证几何分辨率一致且在GIS软件中做过同名点校正。只要有配准误差存在,换更强的模型只会让“亮边”更清晰,不会消失。
5.3 现象:模型推理时显存溢出
训练时候没问题,推理时输入大图OOM。原因是推理阶段经常需要整幅大图(比如8192×8192的遥感影像)直接进入模型,patch序列长度剧增,自注意力的内存占用与序列长度成平方关系。解决方式:滑窗推理——把大图裁成256×256或512×512的重叠窗口,分别推理后再拼接。窗口之间至少要重叠25像素,否则接缝处会出现明显的条带伪变化。这里我建议在推理代码中直接实现重叠拼接逻辑,而不是从网上找通用大图推理脚本临时改。
5.4 现象:预测图碎斑很多,像个“芝麻糊”
变化区域被割裂成大量细碎小区域,中间夹杂着很多孤立的噪点。这个现象的本质是Transformer在提高召回率的同时,对噪声也更敏感,尤其当测试集影像与训练集影像在色调、时相上有差异时,细微的像元级变化都会被模型放大。解决方式:推理后处理加一个基于连通域的面积过滤——统计每个连通域的面积,面积小于设定阈值(比如总像素数的0.01%)的区域直接置为不变。实现用OpenCV的connectedComponentsWithStats一行就能完成。如果过滤后结果仍然偏碎,再从训练数据角度检查,是否训练集里本身就包含大量低质量标注(比如人工标注边界不连贯)。
5.5 现象:训练集F1很高,验证集却突然暴跌
这是典型的数据泄漏问题。很多源码包在划分训练集和验证集时,直接把影像切成小patch然后随机分配,同一个大图中的相邻patch可能同时出现在训练集和验证集中。由于影像内部有很强的空间自相关性,模型在训练时已经“见过”验证区域附近的纹理信息,验证指标虚高;一旦换到真正未见过的区域,指标立即崩掉。解决方式:按行政区划或图幅边界切分数据,保证同一幢建筑、同一块田不被拆开分到不同集合里。如果数据本身是大图裁patch的形式,要确保裁patch前先把每张大图编好唯一ID,按ID划分数据而不是按patch划。
5.6 现象:源码包报错“找不到model.py”或“No module named 'utils'”
这类问题很常见,多数是项目里用了相对导入,比如from utils import *,换一台机器后工作目录不在项目根目录导致模块搜索不到。解决方式:进入项目根目录,在项目根目录下运行命令,不要直接在IDE里打开某个深层文件就跑脚本;如果源码包的训练脚本在scripts/train.py而模块在src/model.py,需要在scripts目录下加一个sys.path设置的逻辑,或者在项目根目录建立一个.pth或setup.py让包可以被导入。大部分开源项目报这个错,都不是代码逻辑问题,而是路径没有配置好。
6. 进阶评估与调参:把F1从0.8提到0.9的几个实招
指标计算和消融实验是源码级项目最容易被忽视的部分。很多工程只打印一个loss和整体准确率,但变化检测任务里准确率没有参考价值——把全部像素预测为不变,准确率也能超过95%。正确做法是把预测结果和标签逐像素比较得到混淆矩阵,再计算Precision、Recall和F1:
def evaluate_metrics(pred, target): # pred、target均为0/1单通道数组, 类型np.uint8 tp = ((pred == 1) & (target == 1)).sum() fp = ((pred == 1) & (target == 0)).sum() fn = ((pred == 0) & (target == 1)).sum() # tn = ((pred == 0) & (target == 0)).sum() precision = tp / max(tp + fp, 1) recall = tp / max(tp + fn, 1) f1 = 2 * precision * recall / max(precision + recall, 1e-8) iou = tp / max(tp + fp + fn, 1) return precision, recall, f1, iouF1提升到0.8以上以后,继续堆模型层数收益很低,这时候优先做三件事:一是换预训练Backbone,将Encoder换成ImageNet预训练的Swin Transformer或Vision Transformer权重,往往比随机初始化多出2-4个点的F1;二是做针对性后处理,对易混淆类别(裸地变成施工地、水体边缘变化)单独分析,给不同类别设定不同的概率阈值;三是做消融,关掉位置编码、把双分支改成早融合、把Decoder换回纯上采样,各跑一遍,用数据告诉你这个任务在你的数据集上到底靠哪个组件在起作用。
我自己的习惯是每次实验结束,把预测图、标签、原图三张并排保存成一张对比图,攒够一批后快速翻看。这个动作帮我发现了至少三次数据标注错误和一次增强逻辑错误,比盯指标管用得多。变化检测这种像素级任务,盯着数值只会越调越玄学,眼睛才是最后一道验证关口。希望这篇笔记能帮你在Transformer变化检测这条路上少走几段弯路。
本文还有配套的精品资源,点击获取