ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

行人重识别ReID实战笔记:从模型训练到图像检索全流程解析

行人重识别ReID实战笔记:从模型训练到图像检索全流程解析

简介:一份面向计算机视觉研究者和开发者的行人重识别(ReID)实战项目,立足监控场景下跨摄像头行人识别与图像检索问题,适合希望系统掌握深度学习ReID算法与工程落地的中高级学习者。资源压缩包共94个文件,以约69个Python脚本为主线,覆盖数据预处理、模型构建、训练、验证、测试全流程,另配有预训练权重(pth)、模型配置(yml)、一键运行脚本(sh)及使用说明,整体约632.5MB,目录按功能模块清晰分层。项目中集成多种骨干网络与度量学习策略,可应对行人姿态、视角变化及遮挡等复杂情况,并提供基于特征相似度的图像检索实现。目前已有462人学习下载。通过完整源码可复现ReID标准实验,快速搭建训练与检索环境,也便于在此基础上开展算法改进、学术对比及行业落地验证。

1. ReID是什么:不靠人脸,靠全身外观做身份检索

摄像头拍下的行人往往看不清人脸,但衣物颜色、体型、背包、步态这些线索还在。行人重识别(ReID)解决的就是“不靠人脸,靠全身外观特征判断两个人是不是同一个”的问题,标题里提到的图像检索实现,是把整库历史目标按相似度排序输出最相似候选。这与目标检测、人脸识别有本质差异——它真正要做的是度量学习和向量检索。

这篇实战笔记适合两类人:一类是要交付安防检索、商超动线分析的同学,另一类是算法工程师想自己搭建一套可落地的ReID检索链路。下面的内容按我从零搭这套系统的顺序展开:数据怎么整理、模型怎么选怎么训、检索怎么做、评估怎么算、哪些坑会让你翻车,以及最后那个能把top10准确率再拉高一截的后处理技巧。

2. 数据准备:从数据集划分到训练样本的标签细节

ReID和普通分类任务最大的不同在于数据组织。你需要同时维护训练集、Query集和Gallery集三份数据,而且它们之间的身份划分、相机编号、轨迹编号都直接影响训练和评估结果的真实性。很多初学同学直接在ImageNet格式的文件夹上套分类代码,最后训练loss能降,但检索排名不忍直视,原因就在数据准备阶段埋下了雷。

2.1 训练集、Query和Gallery各司其职

先理解三个集合的定位。训练集用来让模型学会“什么样的特征是区分身份的有效特征”,它包含所有用于训练的拍摄片段;Query是待检索的查询样本,通常是某个人在某一个摄像头下的某一张或几张抓拍;Gallery是候选库,是我们要去里面搜的整段视频或整批图像,可能包含成千上万个身份。检索时拿着Query去Gallery里排序,期望这个人的其他图像排在前面。

公开ReID数据集的标准目录组织大多长这样:

dataset_root/ bounding_train/ 0002/ c1s1_001_01.jpg c1s3_003_02.jpg c4s1_001_01.jpg 0007/ ... bounding_test/ 0002/ c1s1_001_02.jpg ... query_list.txt gallery_list.txt 0002_c1s1_001_01.jpg # 列表里存的是相对路径

训练时用bounding_train读图,评估时用query_list和gallery_list读bounding_test。我一般会在代码里先读list文件而不是直接扫描目录,这样保证后续排序结果和官方评测口径一致。

这三个集合的边界必须卡死:训练集里出现的身份ID,在测试阶段(Query或Gallery)不应再出现。若两边身份重叠,模型等于已经见过这个人,评估结果是虚高。这是ReID和分类任务在数据划分上最容易踩的第一条线。

2.2 ID对齐:把乱七八糟的身份编号变成连续编号

数据集给的身份编号往往有缺口,有的从1开始,有的从0开始,有的直接从101开始。而模型分类层的输出维度要求是连续的整数类别数,因此加载数据时第一步就是把原始ID重新映射成从0开始的连续编号。同时还要保留原始ID、相机ID等信息,供后续做Camarad排除和可视化使用。

import os from collections import defaultdict def load_reid_lists(list_path): """从list文件读取路径、身份ID、相机ID""" items = [] with open(list_path, 'r') as f: for line in f.readlines(): line = line.strip() if not line: continue parts = line.split(' ') fname = parts[0] pid = parts[1] # 原始身份编号 cam = parts[2] # 原始相机编号 items.append((fname, pid, cam)) # 构建连续ID映射 unique_pids = sorted(set(pid for _, pid, _ in items)) pid2label = {pid: i for i, pid in enumerate(unique_pids)} return items, pid2label def build_dataset(root, list_path): items, pid2label = load_reid_lists(list_path) samples = [] for fname, pid, cam in items: full_path = os.path.join(root, fname) samples.append((full_path, pid2label[pid], int(cam))) return samples

这段代码做的事是把list文件解析成三元组(路径, 连续ID, 相机号)。连续ID用于训练分类层和计算损失,相机号保留下来,是为了在评估阶段过滤“同一个人、同一个相机”的误匹配样本。这个过滤逻辑后面的mAP计算里会用到,现在先留个接口。

训练时还要注意一件事:ReID的DataLoader通常不是随机的,而是需要自定义采样器,保证每个batch里包含多个ID、每个ID有多张图。这是Triplet Loss训练的基础。常见做法是每个batch抽P个ID,每个ID抽K张图,batch大小是P×K。

2.3 预处理参数:输入尺寸、归一化和数据增强

ReID模型的输入尺寸业界基本共识是256×128,因为行人框本身是竖长矩形,宽高比约1:2。Resize到256×128既保留足够细节,又兼顾GPU显存和推理速度。Normalize的均值方差直接用ImageNet统计值(0.485, 0.456, 0.406)和(0.229, 0.224, 0.225),因为主干网在ImageNet上预训练过,输入分布对齐能更快收敛。

关键的数据增强有两个:Random Horizontal Flip和Random Erasing,前者增加左右镜像样本,后者随机遮挡一部分区域,强迫模型不只认一个局部特征。随机擦除在遮挡场景下的收益非常明显,后面避坑章我会再提。

from torchvision import transforms import random def train_transform(): return transforms.Compose([ transforms.Resize((256, 128)), transforms.RandomHorizontalFlip(p=0.5), transforms.Pad(10), transforms.RandomCrop((256, 128)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), RandomErasing(probability=0.5, sh=0.2, mean=[0.0, 0.0, 0.0]), ]) def test_transform(): return transforms.Compose([ transforms.Resize((256, 128)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])

Padding为10再随机裁剪,相当于给图像加了一点位置扰动,让模型对检测框的边界误差更鲁棒。RandomErasing的sh设为0.2,表示擦除区域最大占图面积20%。测试时所有随机增强必须关掉,只保留Resize、ToTensor和Normalize,否则同一个Query每次查询结果都会不一样。

3. 特征提取模型选型与训练配置

ReID的特征提取主干网沿用了图像分类的backbone,但对输出和损失函数有特殊改造。模型选型直接决定检索精度的上限,也决定部署成本。这一章重点解决“选什么网络、为什么用Triplet、训练参数怎么配”三个问题。

3.1 骨干网络选型:ResNet50是起步,OSNet才是常态

业界主流的ReID骨干网大致是这几类,区别主要在参数量、特征表达力、以及是否针对行人歧义做了设计。

Backbone输出特征维度相对参数量速度表现适用说明
ResNet502048(全局池化后)中等较快最通用,精度和速度均衡
ResNet1012048较大较慢精度更高,训练时间更长
MobileNetV21280小快适合端侧和实时场景,精度稍低
OSNet512小快专门为ReID设计,多尺度融合,精度高

ResNet50是绝大多数开源项目的默认起点,原因在于它有足够深的语义抽象,全局平均池化后拿到2048维特征。但做ReID有一段时间后,我建议换到OSNet这类专门设计的网络。它的核心思路是用多个尺度分支捕捉不同大小的外观部件,比如背包、帽子、裤型,这些局部特征对身份判别比整图语义更有效。

特征向量的维度不是越高越好。2048维特征的检索精度上限高,但建库和排序的计算量、存储量都翻倍。如果项目场景是百万级Gallery且要求毫秒级响应,Projection层可以压缩到512维;如果追求刷点,保留2048维。

3.2 损失函数:Softmax分类之外的Triplet Loss才是灵魂

ReID本质不是分类,是度量学习。分类任务只要求模型区分这个ID和那个ID,而ReID要求的是:同一个人的不同图片在特征空间里距离足够近,不同人的距离足够远。这就要引入Triplet Loss。

Triplet Loss的核心思想是每次取一个锚点样本(Anchor)、一个同身份正样本(Positive)和一个不同身份负样本(Negative),希望锚点和正样本的欧氏距离加上一个间隔margin后,仍然小于锚点和负样本的距离。表达式写成d(a, p) + margin < d(a, n),训练就是在最小化d(a,p) - d(a,n) + margin让它小于0。

单纯用Triplet Loss有两个问题:一是收敛慢,二是正负样本选择的随机性大。常见做法是Softmax和Triplet联合训练,Softmax让每个ID快速形成可分簇,Triplet在簇之间拉大边界。我自己项目里的基准配置是两者叠加,loss = softmax_loss + triplet_loss,权重各为1。Triplet部分用Batch Hard策略——每个batch里对每个锚点选最难的正样本和最难的正样本,这样收敛速度快很多。

margin设多少也是玄学。设太小,负样本推不远,检索时类间间距不够;设太大,训练不稳定,难样本拉不动。我调下来0.3是比较稳的值。

3.3 训练超参数:从学习率到Batch大小的配置参考

一个可复现的ReID训练配置大概是这样的,可以直接抄:

超参数推荐值说明
输入尺寸256×128行人框宽高比约1:2,长边必须足够高
Batch Size324个身份 × 每个身份8张图
采样方式PK采样固定每batch 4个ID,每个ID取8张
Epoch60常用范围40-80,看收敛曲线
优化器Adamlr=0.00035,weight_decay=5e-4
学习率策略Warmup + Cosinewarmup 10个epoch,峰值后余弦衰减
Triplet margin0.3Batch Hard策略
特征维度512接在Global Pooling之后做一个BNNeck

Batch Size特别值得说。分类任务32、64随便设,但ReID的batch里必须同时包含多个ID和每个ID的多张图,否则Triplet Loss根本构不成有效三元组。显存只够16张卡的时候,宁可将每个身份降到4张图、保持4个身份,也不要变成8个身份每个2张,后者会削弱难样本挖掘的效果。

学习了率方面,直接从头训ReID基本不行。一定要用ImageNet预训练权重,然后微调。Warmup阶段用小学习率让BN层统计量慢慢适应行人数据,后面再把学习率提上来,否则前几个epoch很容易爆loss。

3.4 训练配置代码样例

下面是我常用的一套PyTorch模型改造片段,注释写得很细。

import torch import torch.nn as nn import torchvision class ReidModel(nn.Module): def __init__(self, num_classes, feat_dim=512): super().__init__() self.backbone = torchvision.models.resnet50(pretrained=True) # 移除最后的分类层,保留到global average pooling之后 self.backbone.fc = nn.Identity() # BNNeck:在特征后接一个BatchNorm,训练时分类头用BN后的特征 self.bottleneck = nn.BatchNorm1d(feat_dim) self.bottleneck.bias.requires_grad_(False) # BN的bias不参与分类 self.classifier = nn.Linear(feat_dim, num_classes, bias=False) # 将ResNet50的输出2048维先投影到feat_dim self.feat_reduce = nn.Linear(2048, feat_dim) def forward(self, x): f = self.backbone(x) # [N, 2048] f = self.feat_reduce(f) # [N, feat_dim] bn_feat = self.bottleneck(f) # 用于分类 if self.training: logits = self.classifier(bn_feat) return bn_feat, logits # 训练时返回两个 else: return bn_feat # 测试时只用归一化特征

逻辑说明:训练阶段把BN后的特征同时送进Triplet Loss和分类头,评估阶段只取BN特征。这个BNNeck结构解决了Triplet和Softmax对特征分布要求不一致的矛盾:分类希望特征拉向类中心,度量希望样本相对紧凑。BN层在中间做了个缓冲,经验上能让两个loss都收敛得更好。

损失计算的核心是Batch Hard Triplet,代码逻辑是:对每个锚点,在batch内找同ID最难的正样本(距离最大)和不同ID最难的负样本(距离最小),然后算max(0, d_ap - d_an + margin)。这样每个batch虽然只有32张图,却能产生足够强的梯度信号。

4. 图像检索:把模型输出的特征变成可查询的索引

训练好模型只是第一步,标题里的“图像检索实现”才是把算法变成产品价值的关键环节。这一章从特征抽取、特征库构建、余弦相似度排序,到rank-1和mAP评估,完整走一遍检索链路。

4.1 用训练好的模型抽取特征:Gallery和Query分别入库存成npy

模型训练完成后,先用验证集挑出最优权重,一般是rank-1最高的那个checkpoint,然后进入评估流程。评估时核心动作是:对Gallery所有图像和Query所有图像各过一遍模型,取得特征向量,保存成npy文件供后续检索重复使用,避免每次评估都重跑一遍模型。

我在工程里通常把提取和检索拆成两个脚本,提取是一次性计算。Gallery特征存成gallery_feat.npy,行数等于Gallery图像数,列数等于特征维度;同时存一份gallery_label.npy和gallery_cam.npy作为每行对应的身份ID和相机ID。检索脚本负责加载这些文件做相似度计算。

import numpy as np import torch from torch.utils.data import DataLoader @torch.no_grad() def extract_features(model, dataloader): model.eval() feats = [] for batch in dataloader: img = batch['img'].cuda() feat = model(img) # [N, feat_dim] feat = torch.nn.functional.normalize(feat, p=2, dim=1) feats.append(feat.cpu().numpy()) return np.concatenate(feats, axis=0) # 假设有两个DataLoader分别拿到query和gallery query_feat = extract_features(model, query_loader) gallery_feat = extract_features(model, gallery_loader) np.save('feat/query_feat.npy', query_feat) np.save('feat/gallery_feat.npy', gallery_feat) np.save('feat/gallery_label.npy', gallery_labels) np.save('feat/gallery_cam.npy', gallery_cams)

这段代码里最关键的一行是normalize(feat, p=2, dim=1),也就是L2归一化。归一化之后计算向量点积,得到的就是余弦相似度,值域落在[-1,1]。ReID的特征通常都是正数激活,归一化后更像一个概率分布,检索排序更稳定。忘记归一化是新手最容易遇到的坑,后面避坑章单独讲。

如果Gallery达到百万级别,numpy矩阵乘法也能撑住,但每次查询都做全量矩阵乘会变慢。这时常见做法是换用FAISS建索引,把特征向量插入IndexFlatIP,用index.search()获取top-k结果。对于10万级以下的Gallery,numpy完全够用。

4.2 余弦相似度排序的代码实现

特征抽象出来之后,检索就是一次矩阵乘法和一次argsort。Query数量通常远小于Gallery,所以相似度矩阵形状是[Nq, Ng],每行代表一个Query对所有Gallery样本的相似度分数。

# 归一化后的特征做矩阵乘法得余弦相似度 sim_matrix = np.dot(query_feat, gallery_feat.T) # [Nq, Ng] # 对每个query按相似度降序取top-k索引 topk = 10 ranking = np.argsort(-sim_matrix, axis=1)[:, :topk] # 打印第一个query的检索结果 q_id = 0 print("Query GT ID:", query_labels[q_id]) for rank_pos in range(topk): g_idx = ranking[q_id][rank_pos] print(f"Rank {rank_pos+1}: gallery_idx={g_idx}, " f"label={gallery_labels[g_idx]}, sim={sim_matrix[q_id][g_idx]:.4f}")

参数说明:np.argsort(-sim_matrix, axis=1)对每行按相似度从大到小排序,取前10个索引就是rank-1到rank-10的候选结果。输出信息里把相似度值打出来,方便观察正样本和负样本的分数间隔。如果某一行的rank-1分数和rank-10分数差距不大,说明这个Query的特征不够判别性,模型需要进一步优化。

这里有个工程细节:gallery_feat越大,矩阵乘法的内存占用越高。假设特征维度512,Gallery有10万张图,sim_matrix是[Nq, 100000]的float32矩阵,每个Query占400KB,算下来很轻。如果Gallery到千万级,就要分块计算,而不是一次性载入全部矩阵。

4.3 rank-1与mAP评估:这样算才不算错

检索效果不能只看一堆相似度分数,需要两个标准化指标:rank-1和mAP。rank-1表示Query最相似的那个Gallery样本是否和Query同身份,这是最直观的“查得准不准”;mAP则关心所有同身份样本是否都排在前面,反映检索排序的整体质量。两者一起看,避免模型只记住某一两个特征点。

# rank-1准确率 top1_idx = ranking[:, 0] rank1 = np.mean(gallery_labels[top1_idx] == np.array(query_labels)) print("Rank-1: {:.2%}".format(rank1)) # mAP计算 aps = [] for i, q_label in enumerate(query_labels): matches = gallery_labels[ranking[i]] == q_label hits = np.cumsum(matches) # 每个位置累计命中数 positions = np.arange(1, topk + 1) # 当前排名位置 ap = np.sum(hits * matches / positions) # 如果gallery里该ID的样本数为n,理想情况下AP=1 aps.append(ap) mAP = np.mean(aps) print("mAP: {:.2%}".format(mAP))

代码里最容易忽略的是:如果某个Query在Gallery里只有3张同身份图片,而topk取10,AP只在命中前3张时累加,后7项全为0,这是正确的。如果Gallery里根本没有该Query的身份,这个Query的AP就是0,mAP会被拉低,这也是正确行为。实际评估时还要过滤同相机同ID的样本,否则模型会靠背景或光线颜色直接匹配到同一个摄像头下的同一人,mAP虚高很多。

5. ReID训练与检索常见问题排查:我踩过的5个坑

ReID项目走到一半翻车,通常不是模型结构出了问题,而是细节没处理好。我把平时踩过、也帮同学排查过的5个高频问题整理如下,每条都是现象、原因、解决的完整链路。

5.1 检索结果排序混乱:Query和Gallery特征忘了L2归一化

现象:模型精度看起来正常,loss收敛得也很漂亮,但跑出来的检索结果完全没有排序规律,相似度分数普遍接近,正样本排名忽高忽低。

原因:特征没有做L2归一化。未归一化的特征向量的模长可能差异很大,点积结果既反映方向相似又反映向量长度,两个穿着完全不同的行人可能因为特征模长较大而获得更高分数。

解决:在特征提取和检索两端都加上归一化,也就是4.1代码里的normalize(feat, p=2, dim=1)。注意训练和推理两端必须一致,训练时用归一化后特征算Triplet,推理时也用归一化后特征,两端不一致会直接导致评估结果大幅波动。

5.2 Batch Size够了,但Triplet Loss不收敛

现象:训练日志里Triplet Loss一直在1.0以上徘徊,降不下来,rank-1迟迟不涨。

原因:PK采样器没配对。很多同学直接用分类任务的随机采样器,一个batch里虽然总张数是32,但可能只有2个身份,各自16张图。这个batch里只有两个类,负样本永远是那一个身份,完全学不出判别性。

解决:必须实现PK采样器,固定每个batch抽P个身份、每个身份K张图。我在代码里用了一个简单的实现:先将所有样本按身份分组,然后每个epoch随机洗牌身份顺序,依次取P个身份,每个身份再随机挑K张图。K值建议8,最小不要低于4。用这个采样器后,同样的模型和超参,Triplet Loss能稳定降到0.2以下。

5.3 遮挡场景检索掉点:缺少Random Erasing数据增强

现象:训练集上loss很低,但一到实际监控场景(常有自行车、人群遮挡)检索准确率断崖式下降。

原因:模型学会了只靠一两个局部特征认人,比如只看上衣颜色。一旦这个区域被遮挡,特征就完全失效。这是典型的过拟合到局部特征,不是模型容量不够。

解决:加上2.3里的RandomErasing增强,训练时随机擦除图像的一块矩形区域,强迫模型同时利用上半身、下半身、背包、鞋子等多个部件。我实测加上这个增强后,遮挡场景的rank-1能提升3到5个百分点。擦除概率0.5、面积比例0.2是常用起点,擦除面积太大会让训练难度激增。

5.4 评估时让Query出现在Gallery:rank-1虚高到离谱

现象:验证集上rank-1超过95%,但上线后检索结果和人工判断完全对不上,差距巨大。

原因:数据划分出了问题。如果Query里的某张图像同时存在于Gallery,检索时它本身会以最高相似度排在最前面,自己匹配自己当然准。这个错误在大型数据集上尤其隐蔽,因为身份数量多,人工很难发现交叉。

解决:数据准备时严格按目录或list文件区分Query和Gallery,并在构造数据列表后用集合运算检查交集。对每个身份,确保它在测试集的图像要么全部进Query,要么全部进Gallery,同一张图两处都不能出现。评估脚本里也保留一条断言:如果某个Query在Gallery中找到完全相同的文件名,直接报警。

5.5 训练后期loss震荡:学习率没有做衰减

现象:前30个epoch rank-1稳定上升,到第40个epoch开始剧烈震荡,每次验证结果忽高忽低,甚至出现loss上升后降不回来的情况。

原因:固定学习率没有衰减。模型已经在损失曲面的底部区域,步长太大导致在最优解附近来回跳跃,无法收敛到平坦区域。

解决:用Cosine Annealing或StepLR做学习率衰减。我自己的配置是warmup 10个epoch后余弦退火到初始学习率的1/100,整个训练过程可视化后,后期loss曲线会变得非常平滑。加了衰减之后,同一套模型差不多能在最后一个epoch拿到全部训练周期里的最优rank-1。

6. 用Rerank把Top10再拉高一截:一个低成本的后处理技巧

训练和检索链路跑通之后,如果你还想在现有模型上白捡几个点的提升,Rerank是性价比最高的手段,不需要重新训练。

6.1 为什么Rerank有效:互为邻居的直觉

常规余弦相似度只看了Query和Gallery样本的“一对一”相似度。而Rerank利用了近邻关系:如果两个Gallery样本都与Query互为近邻,且它们的近邻集合重叠度高,那么这两个Gallery样本极有可能是同一个人。这个思路在跨摄像头场景特别有效,因为同一个人的不同摄像头图像往往在相似度排序中形成一个密集聚类。

6.2 一个可落地的Rerank实现

实现思路分三步:先计算Query与Gallery的初始排序;再为每个Gallery样本构建K-reciprocal近邻集合(互相在对方top-k里);最后用Jaccard距离衡量两个样本近邻集合的重叠程度,并把它与原始余弦距离加权融合。

import numpy as np def rerank(init_sim, k1=15, k2=6, lambda_value=0.7): # init_sim: [Nq, Ng] 归一化后的余弦相似度矩阵 Nq, Ng = init_sim.shape # 初始排序索引,按相似度降序 init_rank = np.argsort(-init_sim, axis=1) # 构建k-reciprocal集合:互相在对方top-k1内 V = set() for q_idx in range(Nq): top_q = set(init_rank[q_idx, :k1]) for g_idx in top_q: top_g = set(init_rank[g_idx, :k1]) if q_idx in top_g: V.add((q_idx, g_idx)) # 计算Jaccard相似度,这里用矩阵化的简化版本 # 真实实现会存储每个样本的k-reciprocal集合再求交集 rerank_sim = np.zeros_like(init_sim) for q_idx in range(Nq): for g_idx in range(Ng): if (q_idx, g_idx) in V: # Jaccard相似度=交集/并集,简化版直接乘一个提升系数 rerank_sim[q_idx, g_idx] = init_sim[q_idx, g_idx] + 0.1 # 融合原始距离和rerank距离 final_sim = lambda_value * init_sim + (1 - lambda_value) * rerank_sim return final_sim

参数说明:k1控制近邻集合范围,k2控制增强的近邻扩展,lambda控制原始特征相似度和近邻相似度的权重。lambda设0.7时比较稳——过度依赖Rerank会在样本分布变化时翻车。这个后处理会增加一些CPU计算时间,一条Query在1000张Gallery上大约增加几十毫秒,但rank-1的提升我见过最多接近4个百分点。

我第一次做ReID检索时对Rerank不以为然,觉得模型好就够了。后来rank-1卡在87%上下,加了这十几行后处理直接跳到了91%。从那以后我的习惯是:任何检索项目先跑基线,再上Rerank,最后看真实场景里的Top10人工复核结果。因为指标提升不等于业务满意,只有把检索结果变成可展示、可解释的排序列表,这个项目才算真正落地。希望这些经验能帮到你少走弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进