打通多个视觉任务的全能Backbone:HRNet

news/2024/7/1 5:17:08

点击上方“小白学视觉”,选择加"星标"或“置顶

重磅干货,第一时间送达

来源:GiantPandaCV

HRNet是微软亚洲研究院的王井东老师领导的团队完成的,打通图像分类、图像分割、目标检测、人脸对齐、姿态识别、风格迁移、Image Inpainting、超分、optical flow、Depth estimation、边缘检测等网络结构。

王老师在ValseWebinar《物体和关键点检测》中亲自讲解了HRNet,讲解地非常透彻。以下文章主要参考了王老师在演讲中的解读,配合论文+代码部分,来为各位读者介绍这个全能的Backbone-HRNet。

1. 引入

689e6d11cfd9b572a94408de923e86ab.png

1b1d149edf898a00c5629861f00f533f.png
网络结构设计思路

在人体姿态识别这类的任务中,需要生成一个高分辨率的heatmap来进行关键点检测。这就与一般的网络结构比如VGGNet的要求不同,因为VGGNet最终得到的feature map分辨率很低,损失了空间结构。

cd2315474868a440600e3440dbe5d444.png
传统的解决思路

获取高分辨率的方式大部分都是如上图所示,采用的是先降分辨率,然后再升分辨率的方法。U-Net、SegNet、DeconvNet、Hourglass本质上都是这种结构。

09e2ca49f695f5ffc5dc4b50e42ca3b0.png
虽然看上去不同,但是本质是一致的

2. 核心

3599d1b3fa22b48f74001ad19d5ce5f1.png

普通网络都是这种结构,不同分辨率之间是进行了串联

41070e61dbad808dd7fbb5a2960c3630.png
不断降分辨率

王井东老师则是将不同分辨率的feature map进行并联:

ae29861e224ca26e6abfd4eebff862b0.png
并联不同分辨率feature map

在并联的基础上,添加不同分辨率feature map之间的交互(fusion)。

57a5a59c87ff16fd71ae4da4a94cdb07.png

具体fusion的方法如下图所示:

d46458ab3870c2272296524ccc49a8a2.png
  • 同分辨率的层直接复制。

  • 需要升分辨率的使用bilinear upsample + 1x1卷积将channel数统一。

  • 需要降分辨率的使用strided 3x3 卷积。

  • 三个feature map融合的方式是相加。

至于为何要用strided 3x3卷积,这是因为卷积在降维的时候会出现信息损失,使用strided 3x3卷积是为了通过学习的方式,降低信息的损耗。所以这里没有用maxpool或者组合池化。

19eaabb4513f4425317677c0d7203053.png
HR示意图

另外在读HRNet的时候会有一个问题,有四个分支的到底如何使用这几个分支呢?论文中也给出了几种方式作为最终的特征选择。

2f960c81f3969fc2707baa4c80a74bc1.png
三种特征融合方法

(a)图展示的是HRNetV1的特征选择,只使用分辨率最高的特征图。

(b)图展示的是HRNetV2的特征选择,将所有分辨率的特征图(小的特征图进行upsample)进行concate,主要用于语义分割和面部关键点检测。

(c)图展示的是HRNetV2p的特征选择,在HRNetV2的基础上,使用了一个特征金字塔,主要用于目标检测网络。

再补充一个(d)图

54603174cd6a30acd1af7d25fee37b5f.png
HRNetV2分类网络后的特征选择

(d)图展示的也是HRNetV2,采用上图的融合方式,主要用于训练分类网络。

总结一下HRNet创新点

  • 将高低分辨率之间的链接由串联改为并联。

  • 在整个网络结构中都保持了高分辨率的表征(最上边那个通路)。

  • 在高低分辨率中引入了交互来提高模型性能。

3. 效果

d6435f1a2d13d39ac643e00a3fb76aef.png

3.1 消融实验

859fbb0f99d321dd3c95c826a8826f0d.png

  1. 对交互方法进行消融实验,证明了当前跨分辨率的融合的有效性。

205af0b62032f57be0932723833cf8e9.png
交互方法的消融实现
  1. 证明高分辨率feature map的表征能力

81ab1bddf4e8ea16aaa5de2eb4d5c333.png

1x代表不进行降维,2x代表分辨率变为原来一半,4x代表分辨率变为原来四分之一。W32、W48中的32、48代表卷积的宽度或者通道数。

3.2 姿态识别任务上的表现

920960e5a704cf847070ae3ddb774c10.png

d74480ed3c55b6942640ca59d3268533.png

以上的姿态识别采用的是top-down的方法。

cec998fbd2a7c9bdd3ece8ca61dc6903.png
COCO验证集的结果

在参数和计算量不增加的情况下,要比其他同类网络效果好很多。

48a0ea523e1bbf411b139ca53e85276d.png
COCO测试集上的结果

在19年2月28日时的PoseTrack Leaderboard,HRNet占领两个项目的第一名。

57a250887d1b4a5c9813390de78950dd.png
PoseTrack Leaderboard

3.3 语义分割任务中的表现

f58533e34deadeb0c2de5f367338ad25.png

307878a2b22c673899538985aaf64e45.png
CityScape验证集上的结果对比
6df28e5bb85422db155c9146fff03666.png
Cityscapes测试集上的对比

3.4 目标检测任务中的表现

6657a0d5816507591de2ea4a6706e510.png

09f0198f3265fc58247b3532b4bad63d.png
单模型单尺度模型对比
465bef0059a96d228ba6441ca922160e.png
Mask R-CNN上结果

3.5 分类任务上的表现

ded431d20e3b38486127de3d0bf6b816.png

ab7f9e3ee791cf8b695d5d17ca41f749.png

ps: 王井东老师在这部分提到,分割的网络也需要使用分类的预训练模型,否则结果会差几个点。

24ceba95a893e742c411c3a693ec522e.png
图像分类任务中和ResNet进行对比

以上是HRNet和ResNet结果对比,同一个颜色的都是参数量大体一致的模型进行的对比,在参数量差不多甚至更少的情况下,HRNet能够比ResNet达到更好的效果。

4. 代码

f67efe5471002904a9ce55f622456093.png

HRNet( https://github.com/HRNet )工作量非常大,构建了六个库涉及语义分割、人体姿态检测、目标检测、图片分类、面部关键点检测、Mask R-CNN等库。全部内容如下图所示:

a19aaff0dc7f7c5fd1a2f9986f942dcc.png

笔者对HRNet代码构建非常感兴趣,所以以HRNet-Image-Classification库为例,来解析一下这部分代码。

先从简单的入手,BasicBlock

420c7ea83b174c25ef3ee20525a9e55f.png
BasicBlock结构
def conv3x3(in_planes, out_planes, stride=1):"""3x3 convolution with padding"""return nn.Conv2d(in_planes, out_planes, kernel_size=3, stride=stride,padding=1, bias=False)class BasicBlock(nn.Module):expansion = 1def __init__(self, inplanes, planes, stride=1, downsample=None):super(BasicBlock, self).__init__()self.conv1 = conv3x3(inplanes, planes, stride)self.bn1 = nn.BatchNorm2d(planes, momentum=BN_MOMENTUM)self.relu = nn.ReLU(inplace=True)self.conv2 = conv3x3(planes, planes)self.bn2 = nn.BatchNorm2d(planes, momentum=BN_MOMENTUM)self.downsample = downsampleself.stride = stridedef forward(self, x):residual = xout = self.conv1(x)out = self.bn1(out)out = self.relu(out)out = self.conv2(out)out = self.bn2(out)if self.downsample is not None:residual = self.downsample(x)out += residualout = self.relu(out)return out

Bottleneck:

948998dc853766aa2749f49991917765.png
Bottleneck结构图
class Bottleneck(nn.Module):expansion = 4def __init__(self, inplanes, planes, stride=1, downsample=None):super(Bottleneck, self).__init__()self.conv1 = nn.Conv2d(inplanes, planes, kernel_size=1, bias=False)self.bn1 = nn.BatchNorm2d(planes, momentum=BN_MOMENTUM)self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, stride=stride,padding=1, bias=False)self.bn2 = nn.BatchNorm2d(planes, momentum=BN_MOMENTUM)self.conv3 = nn.Conv2d(planes, planes * self.expansion, kernel_size=1,bias=False)self.bn3 = nn.BatchNorm2d(planes * self.expansion,momentum=BN_MOMENTUM)self.relu = nn.ReLU(inplace=True)self.downsample = downsampleself.stride = stridedef forward(self, x):residual = xout = self.conv1(x)out = self.bn1(out)out = self.relu(out)out = self.conv2(out)out = self.bn2(out)out = self.relu(out)out = self.conv3(out)out = self.bn3(out)if self.downsample is not None:residual = self.downsample(x)out += residualout = self.relu(out)return out

HighResolutionModule,这是核心模块, 主要分为两个组件:branches和fuse layer。

b781f1eb2c1271ac75eabf4ac1bb0583.png
class HighResolutionModule(nn.Module):def __init__(self, num_branches, blocks, num_blocks, num_inchannels,num_channels, fuse_method, multi_scale_output=True):'''调用:# 调用高低分辨率交互模块, stage2 为例HighResolutionModule(num_branches, # 2block, # 'BASIC'num_blocks, # [4, 4]num_inchannels, # 上个stage的out channelnum_channels, # [32, 64]fuse_method, # SUMreset_multi_scale_output)'''super(HighResolutionModule, self).__init__()self._check_branches(# 检查分支数目是否合理num_branches, blocks, num_blocks, num_inchannels, num_channels)self.num_inchannels = num_inchannels# 融合选用相加的方式self.fuse_method = fuse_methodself.num_branches = num_branchesself.multi_scale_output = multi_scale_output# 两个核心部分,一个是branches构建,一个是融合layers构建self.branches = self._make_branches(num_branches, blocks, num_blocks, num_channels)self.fuse_layers = self._make_fuse_layers()self.relu = nn.ReLU(False)def _check_branches(self, num_branches, blocks, num_blocks,num_inchannels, num_channels):# 分别检查参数是否符合要求,看models.py中的参数,blocks参数冗余了if num_branches != len(num_blocks):error_msg = 'NUM_BRANCHES({}) <> NUM_BLOCKS({})'.format(num_branches, len(num_blocks))logger.error(error_msg)raise ValueError(error_msg)if num_branches != len(num_channels):error_msg = 'NUM_BRANCHES({}) <> NUM_CHANNELS({})'.format(num_branches, len(num_channels))logger.error(error_msg)raise ValueError(error_msg)if num_branches != len(num_inchannels):error_msg = 'NUM_BRANCHES({}) <> NUM_INCHANNELS({})'.format(num_branches, len(num_inchannels))logger.error(error_msg)raise ValueError(error_msg)def _make_one_branch(self, branch_index, block, num_blocks, num_channels,stride=1):# 构建一个分支,一个分支重复num_blocks个blockdownsample = None# 这里判断,如果通道变大(分辨率变小),则使用下采样if stride != 1 or \self.num_inchannels[branch_index] != num_channels[branch_index] * block.expansion:downsample = nn.Sequential(nn.Conv2d(self.num_inchannels[branch_index],num_channels[branch_index] * block.expansion,kernel_size=1, stride=stride, bias=False),nn.BatchNorm2d(num_channels[branch_index] * block.expansion,momentum=BN_MOMENTUM),)layers = []layers.append(block(self.num_inchannels[branch_index],num_channels[branch_index], stride, downsample))self.num_inchannels[branch_index] = \num_channels[branch_index] * block.expansionfor i in range(1, num_blocks[branch_index]):layers.append(block(self.num_inchannels[branch_index],num_channels[branch_index]))return nn.Sequential(*layers)def _make_branches(self, num_branches, block, num_blocks, num_channels):branches = []# 通过循环构建多分支,每个分支属于不同的分辨率for i in range(num_branches):branches.append(self._make_one_branch(i, block, num_blocks, num_channels))return nn.ModuleList(branches)def _make_fuse_layers(self):if self.num_branches == 1:return Nonenum_branches = self.num_branches # 2num_inchannels = self.num_inchannelsfuse_layers = []for i in range(num_branches if self.multi_scale_output else 1):# i代表枚举所有分支fuse_layer = []for j in range(num_branches):# j代表处理的当前分支if j > i: # 进行上采样,使用最近邻插值fuse_layer.append(nn.Sequential(nn.Conv2d(num_inchannels[j],num_inchannels[i],1,1,0,bias=False),nn.BatchNorm2d(num_inchannels[i],momentum=BN_MOMENTUM),nn.Upsample(scale_factor=2**(j-i), mode='nearest')))elif j == i:# 本层不做处理fuse_layer.append(None)else:conv3x3s = []# 进行strided 3x3 conv下采样,如果跨两层,就使用两次strided 3x3 convfor k in range(i-j):if k == i - j - 1:num_outchannels_conv3x3 = num_inchannels[i]conv3x3s.append(nn.Sequential(nn.Conv2d(num_inchannels[j],num_outchannels_conv3x3,3, 2, 1, bias=False),nn.BatchNorm2d(num_outchannels_conv3x3,momentum=BN_MOMENTUM)))else:num_outchannels_conv3x3 = num_inchannels[j]conv3x3s.append(nn.Sequential(nn.Conv2d(num_inchannels[j],num_outchannels_conv3x3,3, 2, 1, bias=False),nn.BatchNorm2d(num_outchannels_conv3x3,nn.ReLU(False)))fuse_layer.append(nn.Sequential(*conv3x3s))fuse_layers.append(nn.ModuleList(fuse_layer))return nn.ModuleList(fuse_layers)def get_num_inchannels(self):return self.num_inchannelsdef forward(self, x):if self.num_branches == 1:return [self.branches[0](x[0])]for i in range(self.num_branches):x[i]=self.branches[i](x[i])x_fuse=[]for i in range(len(self.fuse_layers)):y=x[0] if i == 0 else self.fuse_layers[i][0](x[0])for j in range(1, self.num_branches):if i == j:y=y + x[j]else:y=y + self.fuse_layers[i][j](x[j])x_fuse.append(self.relu(y))# 将fuse以后的多个分支结果保存到list中return x_fuse

models.py中保存的参数, 可以通过这些配置来改变模型的容量、分支个数、特征融合方法:

# high_resoluton_net related params for classification
POSE_HIGH_RESOLUTION_NET = CN()
POSE_HIGH_RESOLUTION_NET.PRETRAINED_LAYERS = ['*']
POSE_HIGH_RESOLUTION_NET.STEM_INPLANES = 64
POSE_HIGH_RESOLUTION_NET.FINAL_CONV_KERNEL = 1
POSE_HIGH_RESOLUTION_NET.WITH_HEAD = TruePOSE_HIGH_RESOLUTION_NET.STAGE2 = CN()
POSE_HIGH_RESOLUTION_NET.STAGE2.NUM_MODULES = 1
POSE_HIGH_RESOLUTION_NET.STAGE2.NUM_BRANCHES = 2
POSE_HIGH_RESOLUTION_NET.STAGE2.NUM_BLOCKS = [4, 4]
POSE_HIGH_RESOLUTION_NET.STAGE2.NUM_CHANNELS = [32, 64]
POSE_HIGH_RESOLUTION_NET.STAGE2.BLOCK = 'BASIC'
POSE_HIGH_RESOLUTION_NET.STAGE2.FUSE_METHOD = 'SUM'POSE_HIGH_RESOLUTION_NET.STAGE3 = CN()
POSE_HIGH_RESOLUTION_NET.STAGE3.NUM_MODULES = 1
POSE_HIGH_RESOLUTION_NET.STAGE3.NUM_BRANCHES = 3
POSE_HIGH_RESOLUTION_NET.STAGE3.NUM_BLOCKS = [4, 4, 4]
POSE_HIGH_RESOLUTION_NET.STAGE3.NUM_CHANNELS = [32, 64, 128]
POSE_HIGH_RESOLUTION_NET.STAGE3.BLOCK = 'BASIC'
POSE_HIGH_RESOLUTION_NET.STAGE3.FUSE_METHOD = 'SUM'POSE_HIGH_RESOLUTION_NET.STAGE4 = CN()
POSE_HIGH_RESOLUTION_NET.STAGE4.NUM_MODULES = 1
POSE_HIGH_RESOLUTION_NET.STAGE4.NUM_BRANCHES = 4
POSE_HIGH_RESOLUTION_NET.STAGE4.NUM_BLOCKS = [4, 4, 4, 4]
POSE_HIGH_RESOLUTION_NET.STAGE4.NUM_CHANNELS = [32, 64, 128, 256]
POSE_HIGH_RESOLUTION_NET.STAGE4.BLOCK = 'BASIC'
POSE_HIGH_RESOLUTION_NET.STAGE4.FUSE_METHOD = 'SUM'

然后来看整个HRNet模型的构建, 由于整体代码量太大,这里仅仅来看forward函数。

def forward(self, x):# 使用两个strided 3x3conv进行快速降维x=self.relu(self.bn1(self.conv1(x)))x=self.relu(self.bn2(self.conv2(x)))# 构建了一串BasicBlock构成的模块x=self.layer1(x)# 然后是多个stage,每个stage核心是调用HighResolutionModule模块x_list=[]for i in range(self.stage2_cfg['NUM_BRANCHES']):if self.transition1[i] is not None:x_list.append(self.transition1[i](x))else:x_list.append(x)y_list=self.stage2(x_list)x_list=[]for i in range(self.stage3_cfg['NUM_BRANCHES']):if self.transition2[i] is not None:x_list.append(self.transition2[i](y_list[-1]))else:x_list.append(y_list[i])y_list=self.stage3(x_list)x_list=[]for i in range(self.stage4_cfg['NUM_BRANCHES']):if self.transition3[i] is not None:x_list.append(self.transition3[i](y_list[-1]))else:x_list.append(y_list[i])y_list=self.stage4(x_list)# 添加分类头,上文中有显示,在分类问题中添加这种头# 在其他问题中换用不同的头y=self.incre_modules[0](y_list[0])for i in range(len(self.downsamp_modules)):y=self.incre_modules[i+1](y_list[i+1]) + \self.downsamp_modules[i](y)y=self.final_layer(y)if torch._C._get_tracing_state():# 在不写C代码的情况下执行forward,直接用python版本y=y.flatten(start_dim=2).mean(dim=2)else:y=F.avg_pool2d(y, kernel_size=y.size()[2:]).view(y.size(0), -1)y=self.classifier(y)return y

5. 总结

6f7d35a3ee018ca2ad4ae7d4cb740769.png

HRNet核心方法是:在模型的整个过程中,保存高分辨率表征的同时使用让不同分辨率的feature map进行特征交互。

HRNet在非常多的CV领域有广泛的应用,比如ICCV2019的东北虎关键点识别比赛中,HRNet就起到了一定的作用。并且在分类部分的实验证明了在同等参数量的情况下,可以取代ResNet进行分类。

之前看郑安坤大佬的一篇文章CNN结构设计技巧-兼顾速度精度与工程实现中提到了一点:

senet是hrnet的一个特例,hrnet不仅有通道注意力,同时也有空间注意力

-- akkaze-郑安坤

86d5b2bfd8ebd4274ca7617577907201.png
SELayer核心实现

SELayer首先通过一个全局平均池化得到一个一维向量,然后通过两个全连接层,将信息进行压缩和扩展,通过sigmoid以后得到每个通道的权值,然后用这个权值与原来的feature map相乘,进行信息上的优化。

182c9be96002e15d1c54c824a50b2ba0.png
HRNet一个结构

可以看到上图用红色箭头串起来的是不是和SELayer很相似。为什么说SENet是HRNet的一个特例,但从这个结构来讲,可以这么看:

  • SENet没有像HRNet这样分辨率变为原来的一半,分辨率直接变为1x1,比较极端。变为1x1向量以后,SENet中使用了两个全连接网络来学习通道的特征分布;但是在HRNet中,使用了几个卷积(Residual block)来学习特征。

  • SENet在主干部分(高分辨率分支)没有安排卷积进行特征的学习;HRNet在主干部分(高分辨率分支)安排了几个卷积(Residual block)来学习特征。

  • 特征融合部分SENet和HRNet区分比较大,SENet使用的对应通道相乘的方法,HRNet则使用的是相加。之所以说SENet是通道注意力机制是因为通过全局平均池化后没有了空间特征,只剩通道的特征;HRNet则可以看作同时保留了空间特征和通道特征,所以说HRNet不仅有通道注意力,同时也有空间注意力。

HRNet团队有10人之多,构建了分类、分割、检测、关键点检测等库,工作量非常大,而且做了很多扎实的实验证明了这种思路的有效性。所以是否可以认为HRNet属于SENet之后又一个更优的backbone呢?还需要自己实践中使用这种想法和思路来验证。

6. 参考

7fb69d5a06471ab0ebe0abde973e4d43.png

https://arxiv.org/pdf/1908.07919

https://www.bilibili.com/video/BV1WJ41197dh?t=508

https://github.com/HRNet

下载1:OpenCV-Contrib扩展模块中文版教程在「小白学视觉」公众号后台回复:扩展模块中文教程,即可下载全网第一份OpenCV扩展模块教程中文版,涵盖扩展模块安装、SFM算法、立体视觉、目标跟踪、生物视觉、超分辨率处理等二十多章内容。下载2:Python视觉实战项目52讲
在「小白学视觉」公众号后台回复:Python视觉实战项目,即可下载包括图像分割、口罩检测、车道线检测、车辆计数、添加眼线、车牌识别、字符识别、情绪检测、文本内容提取、面部识别等31个视觉实战项目,助力快速学校计算机视觉。下载3:OpenCV实战项目20讲
在「小白学视觉」公众号后台回复:OpenCV实战项目20讲,即可下载含有20个基于OpenCV实现20个实战项目,实现OpenCV学习进阶。交流群欢迎加入公众号读者群一起和同行交流,目前有SLAM、三维视觉、传感器、自动驾驶、计算摄影、检测、分割、识别、医学影像、GAN、算法竞赛等微信群(以后会逐渐细分),请扫描下面微信号加群,备注:”昵称+学校/公司+研究方向“,例如:”张三 + 上海交大 + 视觉SLAM“。请按照格式备注,否则不予通过。添加成功后会根据研究方向邀请进入相关微信群。请勿在群内发送广告,否则会请出群,谢谢理解~

http://lihuaxi.xjx100.cn/news/266773.html

相关文章

Hadoop葵花宝典(一)

1. 描述如何安装配置Hadoop 参考&#xff1a;http://www.cnblogs.com/xia520pi/archive/2012/04/08/2437875.html 安装Linux(虚拟机上)—CentOS&#xff1a;创建用户&#xff0c;关闭防火墙&#xff0c;PS&#xff1a;不关防火墙无法访问Web管理页面&#xff0c;删除和增加节点…

Fine-tune之后的NLP新范式:Prompt越来越火,CMU华人博士后出了篇综述文章

视学算法报道机器之心编辑部CMU 博士后研究员刘鹏飞&#xff1a;近代自然语言处理技术发展的第四范式可能是预训练语言模型加持下的 Prompt Learning。近几年&#xff0c;NLP 技术发展迅猛&#xff0c;特别是 BERT 的出现&#xff0c;开启了 NLP 领域新一轮的发展。从 BERT 开始…

助力高校学子快速上手!昇腾AI处理器应用开发实践一览|华为昇腾师资培训沙龙北京场...

如今&#xff0c;AI技术已渗透到各个行业&#xff0c;随着AI技术应用的蓬勃发展&#xff0c;相关专业的人才缺口也日益增大。为了助力高校人工智能领域人才培养及学科建设&#xff0c;华为通过昇腾师资培训沙龙&#xff0c;面向广大高校教师提供昇腾全栈全场景AI技术知识点培训…

从尾到头打印链表

从尾到头打印链表 【题目】&#xff1a; 输入一个链表的头节点&#xff0c;从尾到头打印每个节点的值。 示例 1&#xff1a; 输入&#xff1a;head [1,3,2] 输出&#xff1a;[2,3,1] 【解题思路】&#xff1a; 栈&#xff1a; 每读出一个结点的值&#xff0c;压入栈中&…

好程序员web前端分享值得参考的css理论:OOCSS、SMACSS与BEM

为什么80%的码农都做不了架构师&#xff1f;>>> 好程序员web前端分享值得参考的css理论&#xff1a;OOCSS、SMACSS与BEM 最近在The Sass Way里看到了Modular CSS typography一文&#xff0c;发现文章在开头部分就提到了OOCSS、 SMACSS、 BEM、这3个词。“如果还不知…

python unsupported operand type(s) for /: 'str' and 'str' can only concatenate str (not int) to s

报错&#xff1a; TypeError: can only concatenate str (not “int”) to str TypeError: unsupported operand type(s) for /: ‘str’ and str’ python代码部分~ 正确代码&#xff1a; a int(input(你离开几小时(h)&#xff1a;)) b int(input(你离开几分钟(min)&#…

MyBatis-Plus为啥这么牛?

点击上方蓝色“方志朋”&#xff0c;选择“设为星标”回复“666”获取独家整理的学习资料&#xff01;转自&#xff1a;ThinkYi链接&#xff1a;http://cnblogs.com/thinkYi/p/13723035.html前言大家有用过MyBatis-Plus&#xff08;简称MP&#xff09;的都知道它是一个MyBatis的…

超赞!arXiv论文如何一键链接解读视频,这个浏览器扩展帮你实现

点击上方“小白学视觉”&#xff0c;选择加"星标"或“置顶”重磅干货&#xff0c;第一时间送达有了这个浏览器扩展&#xff0c;读者就可以在 arXiv 论文页面直接链接到解读视频&#xff0c;真是太方便了。阅读 arXiv 论文时&#xff0c;我们可能会被冗长的篇幅以及有…