pyTorch中Upsample和ConvTranspose区分

在pytorch中,nn.UpsampleConvTranspose(包含nn.ConvTranspose1dnn.ConvTranspose2dnn.ConvTranspose3d)均可以实现上采样。那么是模型设计上应该使用哪一种呢?

实际上,在模型中应该使用哪一种并无严格的约束条件,习惯上可以视设计的网络层的作用而定。

nn.Upsample仅通过插值实现,没有参数也不需要模型训练学习。如果只是想单纯实现特征图上采样或者比较在意模型参数量,那么nn.Upsample必然是很好的选择。

ConvTranspose则是通过转置卷积实现,会引入一定的参数量,故需要进行训练,相对于nn.Upsample其能获得更加细粒度的高频信息。如果想让模型学会如何上采样那么就可以考虑ConvTranspose,如在GAN中生成图像。

当然,两者的适用场景不是绝对的。以UNet为例,在原文中上采样用转置卷积完成,而后续很多实现则是用Upsample+\(1\times 1\)conv实现,这样的一个明显好处是相对于转置卷积来说参数量更少,而在性能上两者几乎是没有区别的。(参考Github作者@jvanvugt的讨论)。

区别总结

Upsample ConvTranspose
实现机制 插值 转置卷积,可训练学习
参数
可处理数据维度 1D、2D、3D ConvTranspose1d:1D ConvTranspose2d:2D ConvTranspose3d:3D
场景 分割、检测等特征上采样 GAN、高分辨率

归一化层及在模型中的使用

在卷积神经网络CNN中,如BN、GN等归一化层得到了普遍使用。这些层之间有什么区别、分别在什么情况下使用我一直是模棱两可,现在特意整理记录一下,搞清楚这些层的原理和区别对设计模型网络也大有裨益。

此外,本文将特意花一些篇幅探讨对各种归一化方法来说,其前边的卷积层是否需要bias,这部分内容在本文第二部分

归一化层区分

归一化用于解决深度学习中的内部协变量移位(internal covariate shift)现象,可以用于缓解梯度爆炸、提高模型收敛速度,同时能起到正则化的作用,防止过拟合。

目前的主流归一化层有Batch Norm(BN)、Layer Norm(LN)、Instance Norm(IN)、Group Norm(GN)。其区别可以参考下图,这张图来自于论文Group Normalization[1]。

norms

归纳起来说,无论是哪一种归一化,本质上都是将特征图分为若干个部分,分别对每个部分做归一化使其数值范围符合特定的正态分布,所以对第i个部分的归一化均可以用下式表示: 不同归一化的区别仅在于如何划分特征图。

定义有一个特征图,我们要对这个特征图做不同的归一化。

Batch Norm(BN)[2]

BN的应用应该说是最广的,但是其对batchsize的大小很敏感,只建议在batchsize不低于8的时候使用。

BN逐通道地对整个batch的特征图做归一化,也就是说每次做归一化的特征图维度为。下边通过代码表示一下计算均值和方差的过程:

1
2
3
# BN
mean = x.mean(dim=(0,2,3)) # shape: [C,]
std = x.std(dim=(0,2,3)) # shape: [C,]

Layer Norm(LN)[3]

LN多用于RNN,在卷积神经网络上很少使用。

与BN相反,LN逐输入地对所有通道的特征图做归一化,每次归一化的特征图维度为,代码表示为:

1
2
3
# LN
mean = x.mean(dim=(1,2,3)) # shape: [N,]
std = x.std(dim=(1,2,3)) # shape: [N,]

Instance Norm(IN)[4]

IN主要用于生成式模型,如基于GAN的图像生成、图像风格迁移等。

IN可以看作是BN或者LN的特例(时的BN或者时的LN),其只对单个特征图做归一化,单特征图维度为,代码表示为:

1
2
3
# IN
mean = x.mean(dim=(2,3)) # shape: [N. C]
std = x.std(dim=(2,3)) # shape: [N, C]

Group Norm(GN)

由于GN的性能不受batchsize影响,在batchsize比较小的时候,可以用GN代码BN。

GN将特征图沿通道均分为组,分别为每一组做归一化,每次归一化的特征图维度为,代码表示为:

1
2
3
4
5
6
7
# GN
x_groups = x.chunk(G, dim=1)
x = torch.cat(x_groups, dim=0) # shape: [N*G, C//G, H, W]
mean = x.mean(dim=(1,2,3)) # shape: [N*G,]
mean = mean.view(N, G) # shape: [N, G]
std = x.std(dim=(1,2,3)) # shape: [N*G,]
std = std.view(N, G) # shape: [N, G]

从卷积说起——是否需要bias?

我们在设计模型结构的时候应该经常看到类似下边的代码:

1
2
3
4
5
6
7
8
9
10
class BaseConv(nn.Module):
def __init__(self, in_channels, out_channels) -> None:
super().__init__()
self.conv = nn.Sequential(
nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=2, padding=1, bias=False),
nn.BatchNorm2d(out_channels),
nn.ReLU(inplace=True)
)
def forward(self, x):
return self.conv(x)

nn.Conv2d中其他参数都很熟悉,bias=False是为什么呢?

本节将探讨这一问题,解决什么归一化不需要bias、为什么不需要的问题。

卷积实现

在pytorch中,卷积通过类 nn.Conv2d()实现,卷积层的参数有权重(weight)和偏置(bias)

定义一个卷积层,其输入通道数为,输出通道数为,卷积核尺寸为

1
conv = nn.Conv2d(C_in, C_out, kernel_size=k, stride=2, padding=1)

那么有

也就是说,对于每个输出层来说,都有一个维度为的卷积核在输入特征图上滑动计算,计算的结果再与一个偏置值相加得到。所以说直接作用在通道方向上,逐通道地加。

逐通道是不是很熟悉?没错,BN也是逐通道的,那么在使用BN的情况下,即使加了bias最后也会在减去均值的时候被去掉,因此,这时候添加bias将不会产生任何作用,反而白白占用显存和运算量。

数学推理证明

我们可以以BN为例推导一下这个过程。为简化过程,只考虑输出特征图的一个通道特征图。那么可以如下计算: 归一化的首先将特征转换为标准正态分布,这个过程计算为: 从过程中可以很明显看到,偏差在计算过程中被完全消去了。

实际上,如果我们分别对LN、IN和GN都按上式计算的话就会发现,bias对于IN来说也是没有意义的,但是对于LN和GN则有意义。

结论

当归一化在与通道垂直的方向上做(逐通道)时,就不应该添加bias。如果模型中使用BN和IN,那么都不应该加bias,而GN和LN则应该加bias。

reference

[1]. Yuxin Wu and Kaiming He. Group Normalization. In ECCV, 2018.

[2]. Sergey Ioffe and Christian Szegedy. Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift. In ICML, 2015.

[3]. Lei Jimmy Ba, Jamie Ryan Kiros, and Geoffrey E. Hinton. Layer normalization. arXiv:1607.06450, 2016.

[4]. Dmitry Ulyanov, Andrea Vedaldi and Victor S. Lempitsky. Instance normalization: The missing ingredient for fast stylization. arXiv:1607.08022, 2016.

linux中非root权限用户python配置详解

在使用Linux的过程中,经常需要用到系统默认版本之外的python版本,甚至是需要自行安装符合需求的版本。对于没有root权限的用户来说,配置起来似乎非常困难,实际上这一切确实是不需要root权限就可以完成的。

值得补充的是,本文介绍的方法不局限于python,对于用户自己安装的软件、包等也是一样的步骤,掌握了原理,方法便可以一通百通。

.local作用

这部分主要介绍.local文件夹,若对此不感兴趣可以跳过,不会影响后续环境配置。

提到为单个用户配置特定单用的数据就不能不说.local文件夹,这是一个路径固定为/home/[username]/.local的文件夹,用于存储专用于用户(user-specific)的程序数据,比如安装的用户程序、python模块等。同时由于.local处于用户的根目录下,对其操作不需要root权限。

所以,在单个用户配置python、pip等环境时,理论上都应该和.local文件夹打交道(实际上,不使用.local也是可以的,但是从规范的角度看,.local无疑是最推荐的)。

链接到自己所需的版本

这里介绍最常遇到的情况,即系统中存在多个版本的python,而默认的python又不是自己想要的。举例来讲,比如系统中同时安装有python2.7和python3.8,默认python是2.7,想要将python3.8作为默认python就需要我们自己做链接了。

先说一下原理:先创建一个软链接.local/bin/python指向python3.8,然后将.local/bin添加到环境变量PATH最前边。

这样系统将优先从.local/bin中找python,从而可以顺理成章得根据软链接找到python3.8了。

step1:找到python3.8及其pip的安装路径。

注意配置的时候建议一并配置pip,否则可能会出现python和pip版本不一致的情况。

1
2
which python3.8
which pip3.8

一般来讲,系统中python的安装路径都在/usr/bin下,这里就以/usr/bin/python3.8/usr/bin/pip3.8为例继续。

step2:创建软链接:

1
2
3
4
5
# 将原本的链接删除,若原本没有可跳过
rm ~/.local/bin/python ~/.local/bin/pip
# 创建软链接,指向自己想要的版本
ln -s /usr/bin/python3.8 ~/.local/bin/python
ln -s /usr/bin/pip3.8 ~/.local/bin/pip

step3:.local/bin添加到环境变量PATH中(若已添加,可跳过):

1
2
3
4
5
6
# 打开~/.bashrc文件,进入编辑模式
vim ~/.bashrc
# 添加环境变量
export PATH=~/.local/bin/:$PATH
# 退出后 source一下使其生效
source ~/.bashrc

这样,新的python就配置好了,可以在命令行输入一下python试试~

安装自己所需的版本

这里介绍相对不常遇到的情况,即系统中没有安装自己想要的python。比如系统中安装了python2.7和python3.8,而自己需要python3.6,那就需要用户自己安装一个了。

同样也是先说原理:在用户目录下安装python,之后和上一节一样将其链接到.local/bin/python中即可。

step1:下载和准备工作。

python官网下载自己需要的python版本,这里即以安装python3.6.9为例,其他版本也是一样的操作。

1
2
3
4
5
6
7
8
# 下载
wget https://www.python.org/ftp/python/3.6.9/Python-3.6.9.tgz
# 解压
tar zxfv Python-3.6.9.tgz
# 添加权限,否则无法执行安装
find Python-3.6.9/Python -type d xargs chmod 0755
# 进入解压后的目录
cd Python-3.6.9

step2:执行安装。

注意这里--prefix后的路径就是python的安装路径,此路径必须是绝对路径,默认我们将其安装在.local

1
2
3
4
5
./configure --prefix=/home/[username]/.local
make
make install
# 退出路径
cd

至此,python3.6就安装完成了,后边的步骤其实和上一节的步骤是一样的。

step3:设置python和pip软链接。

.local/bin中原本没有pythonpip,这一步可跳过,因为在安装的时候这些软链接就已经自动配置好了。否则就需要手动设置,步骤和上一节step2是相同的。

1
2
3
4
5
6
cd .local/bin
# 可以先ls 看一下有没有名为python和pip的文件,没有的话可以跳过step3
rm python pip
# 设置软链接,注意安装的python3.6其实也在这一级目录里
ln -s python3.6 python
ln -s pip3.6 pip

step4:.local/bin添加到环境变量PATH中,和上一节step3是一样的。

同样若.local/bin已被添加到环境变量中的话可跳过。

1
2
3
vim ~/.bashrc
export PATH=~/.local/bin/:$PATH
source ~/.bashrc

这样,就可以开心地使用自己安装的版本了。

ResNet残差块详解

论文链接:Deep Residual Learning for Image Recognition

ResNet在深度学习领域可以说是一个人尽皆知的模型,在各种模型的backbone上得到了广泛的应用。其理论简洁、结构简单,因此往往会让人忽视其巧妙的设计。我在最近设计模型结构时逐渐发现这一点,故写此文总结一下。

再看一遍残差块

这里简单回顾一下残差块和跳跃链接,如果足够熟悉的话可以跳过。

在ResNet出现之前,人们发现随着神经网络层数的增加,梯度消失现象越来越严重,模型的性能经常不再增加甚至还有所下降,这使得模型的深度受到限制。

ResNet使用残差模块引入跳跃连接(skip connection)结构解决了这一问题。然而这不是什么复杂的运算,其公式简单地令人咂舌: 这里表示一个残差块的输入特征图,表示经过若干卷积块后的结果。

以前的模型直接将作为最终输出结果,而ResNet创造性的将其与相加的结果作为最终结果。对应的意义是最终想要的知识是,已经学到了知识,那么残差块里边的网络只需要学习到知识即可

Residual learning: a building block.

图1:一个ResNet残差块结构图

残差块结构

很多人(其实是我)可能没有注意到的一点是ResNet提供了两种残差块。第一种被称为building block,主要用于浅层网络,第二种被称为bottleneck,主要用于深层网络。两者结构如图2所示。

A deeper residual function

图2:左图为building block,右图为bottleneck

值得注意的是,上一部分的公式只是一个简化模型,并不严格与实现一致。实际上,残差块的最后一层的激活函数是在与相加后才使用的。

代码实现图2中的卷积块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class ConvBlock(nn.Module):
def __init__(self, in_channels, out_channels, k_size=3, activation=True):
'''
k_size: kernel size, by default is 3
activation: whether use activation
'''
super().__init__()
self.blocks = [
nn.Conv2d(in_channels, out_channels, kernel=k_size, stride=1, padding=(k_size-1)//2, bias=False),
nn.BatchNorm2d(out_channels)
]

if activation:
self.blocks.append(
nn.ReLU(inplace=True)
)

self.conv = nn.Sequential(*self.blocks)

def forward(self, x):
return self.conv(x)

building block

building block只堆叠了两个相同的卷积块,这样简单的结构适用于通道比较窄的情况。所以其主要用于浅层网络,如ResNet18和ResNet34。

代码实现:

1
2
3
4
5
6
7
8
9
10
11
class BuildingBlock(nn.Module):
def __init__(self, channels):
super().__init__()
self.block = nn.Sequential(
ConvBlock(channels, channels),
ConvBlock(channels, channels, activation=False)
)
self.act = nn.ReLU(inplace=True)

def forward(self, x):
return self.act(x + self.block(x))

bottleneck

相比于building block的简单结构,bottleneck设计则非常巧妙。在通道非常宽的情况下(如1024)直接堆叠卷积块将会使参数量非常巨大。

因此bottleneck堆叠了三个卷积块,其中第一个卷积块用于降低通道数量,第三个卷积块用于复原通道数量。第一个和第三个卷积块涉及到的通道比较宽因此使用卷积,第二个卷积块涉及到的通道比较窄因此使用卷积。

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
class Bottleneck(nn.Module):
def __init__(self, in_channels, mid_channels):
super().__init__()
self.block = nn.Sequential(
ConvBlock(in_channels, mid_channels, k_size=1),
ConvBlock(mid_channels, mid_channels, k_size=3),
ConvBlock(mid_channels, in_channels, k_size=1, activation=False),
)
self.act = nn.ReLU(inplace=True)

def forward(self, x):
return self.act(x + self.block(x))

一些思考

ResNet的残差块设计个人觉得真是非常巧妙,所谓大道至简,最有效的东西未必总是要很复杂。

此外,bottleneck的设计也非常精彩,其可以在不影响性能的情况下有效减少参数量。在实际模型设计的时候我们经常要考虑参数量、运算量等,bottleneck的结构可以作为一个不错的参考。

深入探究COCO数据集(三)

本文是专题 深入探究COCO数据集标签 的第三部分,主要讲解如何加载COCO style的数据集。其他部分链接:

深入探究COCO数据集(一)——COCO标签格式

深入探究COCO数据集(二)——制作COCO style的数据集

pycocotools

pycocotools提供了COCO数据集交互的接口,加载COCO style的数据集通常使用其完成,更准确地说,是通过pycocotools.coco中的COCO类实现。标签加载过程如下:

1
2
from pycocotools.coco import COCO
coco = COCO(ann_file)

得到一个COCO对象之后呢?别急,先来说几个COCO类中常用的接口:

属性

常用属性的类型均为字典。

名称 说明
anns ann_id 字典,标注文件中对应的标注信息 ann_id快速索引标注
imgs image_id 字典,标注文件中对应的图像信息 image_id快速索引图像
cats category_id 字典,标注文件中对应的类别信息 category_id快速索引类别
imgToAnns image_id 列表,元素为字典形式的标注信息 image_id快速索引该图像全部标注
catToImgs category_id 列表,元素为image_id category_id索引有该类别标注的全部图像的id

方法

getAnnIds()

  • 参数:imgIds:待查询image_id(可选)、catIds:待查询category_id(可选)。

  • 返回值:符合全部筛选条件的ann_id列表,如果没有入参返回包含全部ann_id的列表。

  • 示例:

    1
    2
    >>> coco.getAnnIds(imgIds=100)
    [246, 247]

getImgIds()

  • 参数:imgIds:待查询image_id(可选)、catIds:待查询category_id(可选)。

  • 返回值:符合全部筛选条件的image_id列表,如果没有入参返回包含全部image_id的列表。

  • 示例:

    1
    2
    >>> coco.getImgIds(imgIds=[1, 10, 100], catIds=6) # 三张图中包含category_id=6标注的图
    [100]

getCatIds()

  • 参数:catNms:类别名字(可选)、supNms:父类名字(可选)、catIdscategory_id(可选)。

  • 返回值:符合全部筛选条件的category_id列表,如果没有入参返回包含全部category_id的列表。

  • 示例:

    1
    2
    >>> coco.getCatIds(catNms="chairs", catIds=6)
    [6]

loadAnns()

  • 参数:idsann_id(可选)。

  • 返回值:标注文件中对应传入ann_id的标注信息列表,如果没有入参返回包含全部标注的列表。

  • 机制:通过anns属性实现。

  • 示例:

    1
    >>> coco.loadAnns(ids=[2, 3])

loadImgs()

  • 参数:idsimage_id(可选)。

  • 返回值:标注文件中对应传入image_id的图像信息列表,如果没有入参返回包含全部图像信息的列表。

  • 机制:通过imgs属性实现。

  • 示例:

    1
    >>> coco.loadImgs(ids=0)

loadCats()

  • 参数:idscategory_id(可选)。

  • 返回值:标注文件中对应传入category_id的类别信息列表,如果没有入参返回包含全部类别信息的列表。

  • 机制:通过cats属性实现。

  • 示例:

    1
    >>> coco.loadCats(ids=6)

annToMask()

  • 参数:ann:标注信息字典。

  • 返回值:ann['segmentation']中的多边形对应的掩膜,类型为np.ndarray

  • 示例:

    1
    mask = coco.annToMask(coco.anns[0])

COCO style数据加载

其实加载COCO数据集的过程无非就是运用上述属性和方法获得对应信息的过程,掌握了上述接口加载数据就很简单了。

由于很多开源代码中都写有COCO的数据加载类,大多数情况下我们无须自己重写,但是在一些特殊情况下需要根据需求重写或修改,个人比较推荐参考YOLACT代码中的数据加载,因为代码逻辑比较简单清晰。

这里放一个简单的目标检测数据加载模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
import cv2
import os.path as osp
import numpy as np

class COCODataset():
"""
COCO style dataset
"""
def __init__(
self,
data_dir=None,
json_file="coco_train.json",
name="train",
area_threshold=-1,
):
"""
Dataset initialization. Annotation data are read into memory by COCO API.
Args:
data_dir (str): dataset root directory
json_file (str): annotation json file name
name (str): data name (e.g. 'train' or 'val')
area_threshold(int): area threshold for object
"""
self.area_thres = area_threshold
self.data_dir = data_dir
self.json_file = json_file
self.imgs = None
self.name = name
self.init()

def init(self):
from pycocotools.coco import COCO
self.coco = COCO(osp.join(self.data_dir, "annotations", self.json_file))

# NOTE: do not use self.coco.getImgIds()
self.ids = list(self.coco.imgToAnns.keys())
self.class_ids = sorted(self.coco.getCatIds())
self.cats = self.coco.loadCats(self.coco.getCatIds())
self._classes = tuple([c["name"] for c in self.cats])

self.annotations = self._load_coco_annotations()

def __getitem__(self, index):
# NOTE: in case empty label
max_iter = 5
for _ in range(max_iter):
res, img_info, _ = self.annotations[index]
if res.size > 0:
break
index = np.random.randint(0, self.__len__())
id_ = self.ids[index]

if self.imgs is not None:
pad_img = self.imgs[index]
img = pad_img[: img_info[0], : img_info[1], :].copy()
else:
img = self.load_image(index)

return img, res.copy(), img_info, np.array([id_])

def __len__(self):
return len(self.ids)

def _load_coco_annotations(self):
return [self.load_anno_from_ids(_ids) for _ids in self.ids]

def load_anno_from_ids(self, id_):
im_ann = self.coco.loadImgs(id_)[0]
width = im_ann["width"]
height = im_ann["height"]
anno_ids = self.coco.getAnnIds(imgIds=[int(id_)], iscrowd=False)
annotations = self.coco.loadAnns(anno_ids)
objs = []
for obj in annotations:
x1 = np.max((0, obj["bbox"][0]))
y1 = np.max((0, obj["bbox"][1]))
x2 = np.min((width, x1 + np.max((0, obj["bbox"][2]))))
y2 = np.min((height, y1 + np.max((0, obj["bbox"][3]))))
# NOTE: we filter the small object, which is different from the super method
area_thres = max(self.area_thres, 0)
if obj["area"] > area_thres and x2 >= x1 and y2 >= y1:
obj["clean_bbox"] = [x1, y1, x2, y2]
objs.append(obj)

num_objs = len(objs)

res = np.zeros((num_objs, 5))

for ix, obj in enumerate(objs):
cls = self.class_ids.index(obj["category_id"])
res[ix, 0:4] = obj["clean_bbox"]
res[ix, 4] = cls

img_info = (height, width)
file_name = (
im_ann["file_name"]
if "file_name" in im_ann
else "{:012}".format(id_) + ".jpg"
)

return (res, img_info, file_name)

def load_image(self, index):
file_name = self.annotations[index][-1]
assert osp.exists(file_name), f"file named {file_name} not found"
img = cv2.imread(file_name)

return img

至此,深入探究COCO数据集标签这一专题就结束了,COCO数据集被广泛使用于检测和分割领域,即使我们不直接使用COCO数据集本身,掌握其标签格式进而能够创建和加载自己的COCO style数据集在使用相关模型的时候也很有益。希望本专题能抛砖引玉,给读者提供一些参考。

深入探究COCO数据集(二)

本文是专题 深入探究COCO数据集标签 的第二部分,主要讲解如何制作COCO style的数据集。其他部分链接:

深入探究COCO数据集(一)——COCO标签格式

深入探究COCO数据集(三)——加载数据集

制作COCO style的检测数据集

在第一部分中我们详细讲了COCO的标签结构,知道了COCO数据集标签的构成,自己制作COCO style的数据集就很简单了,这里依然只从标签的角度讲解。

为简单起见,这里以两类为例,类别分别为AB。 假如我要做一个检测任务,目前我的每一个图像都有一个对应的.txt文件记录图像的标签信息,文件中每一个标签是一个五元组,第一个值为类别索引,其余4个分别为边界框左上和右下角坐标。

那么一开始就可以先这样预定义标签:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
label_json = {
"info" : {
"contributor" : "",
"date_created" : "",
"description" : "",
"url" : "",
"version" : "",
"year" : ""
},
"licenses" : [{"name": "", "id": 0, "url": ""}],
# the main annotation infomation
"categories" : [
{"supercategory" : "", "name": 'A', "id": 1},
{"supercategory" : "", "name": 'B', "id": 2},
],
"images" : [],
"annotations" : [],
}

之后依次从.txt文件中读取标签,将标签和对应文件信息写入字典label_json中的images项和annotations项即可。程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import os
import cv2
import json
import warnings
import numpy as np
# root
# ├──images
# │ ├──train
# │ └──val
# ├──labels
# │ ├──train
# │ └──val
# └──annotation
# └── annotation.json
json_path = "annotation/annotation.json"
ann_id = 0
for id, file in enumerate(txt_list):
img_path = file.replace(
"labels", "images"
).replace(
".txt", ".jpg"
)
if not os.path.exists(img_path):
continue

H, W = cv2.imread(img_path).shape[:2]
# part1: images infomation
img_info = {
"license" : 0,
"filename": img_path,
"height": H,
"width": W,
"id": id
}
label_json["images"].append(img_info)

# labels
with warnings.catch_warnings():
# ignore the warning information when the file is empty
warnings.simplefilter("ignore")
labels = np.loadtxt(file)

targets = np.zeros((len(labels), 5))
if labels.size == 0:
continue
if labels.ndim == 1:
labels = labels[None]
# part2: annotations information
for label in labels:
target_info = {
"segmentation" : [],
"category_id" : int(label[0]),
"image_id" : id,
"id" : ann_id,
"area" : round((label[3]-label[1])*(label[4]-label[2]), 6),
"bbox" : xyxy2xywh(label, (H,W)),
"iscrowd" : 0,
}
ann_id += 1
label_json["annotations"].append(target_info)
# save the labels in a json file
with open(json_path, 'w') as json_f:
json.dump(label_json, json_f)

至此,COCO style的目标检测数据集就构建完成了。

从二维掩膜构建COCO style数据集

上边分析过了如何制作COCO style的目标检测数据集,那么如果要做COCO style的分割数据集该怎么实现呢? 其实这个过程和上边是一样的,难度仅在于如何将二维掩膜转换成COCO的polygon形式。polygon形式无非就是物体边缘轮廓上的一系列点集,要想得到就必须找到物体的轮廓,这一功能可以使用openCV中的findContours函数实现:

1
2
3
4
5
contours, _ = cv2.findContours(
mask,
cv2.RETR_TREE,
cv2.CHAIN_APPROX_NONE
)

篇幅所限,这里不再详细讲解cv2.findContours的用法,对于二维掩膜来说,上边的代码足矣。返回的contours是一个列表结构,列表中的每一项都是一个封闭的轮廓点集,接下来遍历这些轮廓依次添加即可。

通常在实际情况中,我们往往还会有些“特别”的要求,本文给出两种情况考量。

  1. 轮廓点集太过密集,能不能用更少的点表示? 当然是可以的,openCV中的cv2.approxPolyDP 可以实现这个功能,其将轮廓形状近似到另外一种由更少点组成的轮廓形状,新轮廓的点的数目准确度(函数第二个参数)来决定;
  2. 能不能补充上面积和边界框信息? cv2.contourArea可以获得轮廓的面积信息,cv2.boundingRect可以获得轮廓的边界框,返回[x,y,w,h]格式的边界框。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
annotations = []
for contour in contours:
# contour approximate
epsilon = 0.01 * cv2.arcLength(contour, True)
contour = cv2.approxPolyDP(contour, epsilon, True)

polygon = []
for point in contour:
polygon.extend(
[point[0][0], point[0][1]]
)
# get area
area = cv2.contourArea(contour)
# get bounding box
bbox = cv2.boundingRect(contour)

annotations.append([polygon, area, bbox])

标签中其他信息如image_idcategory_id等获得方法和获取检测数据集一样,在此不再赘述。

深入探究COCO数据集(一)

COCO数据集是目标检测和实例分割领域的经典数据集,从事相关领域自然免不了与其打交道。网上对于COCO数据集的介绍已经很多,这里不再赘述。本部分主要从标签的角度探究一下COCO数据集的检测部分,由于内容较多,我将其整理为一个专题,分为三个部分,分别是:

深入探究COCO数据集(一)——COCO标签格式

深入探究COCO数据集(二)——制作COCO style的数据集

深入探究COCO数据集(三)——加载数据集

本节是专题的第一部分,主要分析讲解一下COCO数据集的标签格式。

COCO标签格式

COCO标签以json文件的形式存储,整个标签文件可以视为一个字典,其中有5个键值对,键(key)分别是infolicensescategoriesimagesannotations

1
2
3
4
5
6
annotation
├──info
├──licenses
├──categories
├──images
└──annotations

info提供数据集的一些信息,如contributordescription等。

licenses提供数据集使用的许可证。

infolicenses并不提供数据集标签的重要信息。在val2017中这两项的值为(licenses仅列出部分):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# info
"info": {
"description": "COCO 2017 Dataset",
"url": "http://cocodataset.org",
"version": "1.0",
"year": 2017,
"contributor": "COCO Consortium",
"date_created": "2017/09/01"
}
# licenses
"licenses": [
{
"url": "http://creativecommons.org/licenses/by-nc-sa/2.0/",
"id": 1,
"name": "Attribution-NonCommercial-ShareAlike License"
},
# ......
{
"url": "http://www.usa.gov/copyright.shtml",
"id": 8,
"name": "United States Government Work"
}
]

categories是一个列表,提供数据集每一个类的类别信息。每一类用列表中的一个字典表示,字典键值对有唯一类别索引id,类别名称name和类别父类supercategory

1
2
3
4
5
"categories": [
{"supercategory": "person", "id": 1, "name": "person"},
# ......
{"supercategory": "indoor", "id": 90, "name": "toothbrush"}
]

images是一个列表,提供数据集中全部图像的信息,列表中每一项是一个字典,代表一张图像信息。字典中比较重要的键是图像文件名file_name、图像高度height、图像宽度width以及唯一图像索引id

1
2
3
4
5
6
7
8
9
10
11
12
13
"images": [
{
"license": 4,
"file_name": "000000397133.jpg",
"coco_url": "http://images.cocodataset.org/val2017/000000397133.jpg",
"height": 427,
"width": 640,
"date_captured": "2013-11-14 17:02:52",
"flickr_url": "http://farm7.staticflickr.com/6116/6255196340_da26cf2c9e_z.jpg",
"id": 397133
},
# ......
]

annotations也是一个列表,提供数据集中图像对应的标签信息,列表中每一项是一个字典,对应于一个标签。字典中的键分别为:

  • segmentation:每个实例的轮廓顶点,值是二维列表,这主要是考虑到物体存在被截断的情况。其形式为\([[x^1_1, y^1_1, x^1_2, y^1_2, ... x^1_m, y^1_m], ..., [x^k_1, y^k_1, x^k_2, y^k_2, ... x^k_n, y^k_n]]\),这表示该实例由k个截断的区域构成,每个区域的轮廓顶点表示为一个一维列表。此外,x表示宽度方向,y表示高度方向。
  • area:实例轮廓包围区域的面积
  • bbox:实例对应的最小边界矩形框,是一个以xywh形式存储的列表,四个值分别表示边界框左上角坐标、宽度和高度
  • image_id:该标签对应的图像索引号,该索引对应于images项中的id
  • category_id:该标签对应的类别索引号,该索引号对应于categories项中的id
  • id:唯一标注索引
  • iscrowd:是否密集标注
1
2
3
4
5
6
7
8
9
10
11
"annotations": [
{
"segmentation": [[363.0,213.11,561.69,164.4,640.0,142.43,640.0,301.0,356.31,240.82]],
"area": 25818.402449999994,
"iscrowd": 0,
"image_id": 25181,
"bbox": [356.31,142.43,283.69,158.57],
"category_id": 7,
"id": 173761
},
]

id,id,id......

在COCO标签中,categories项、images项和annotations项中都有名为id的键,此外还有image_idcategory_id,初看很容易搞混。然而区分这几个id的不同对于理解COCO标签非常必要,上边其实已经说过了,这里再放在一起集中说一下。

首先,每一项中的id都相当于“身份证”,是一个内容(类别、图像或标签)的唯一标识,给一个id就能找到对应的内容。如在categories中就是类别的唯一标识、在images中就是图像的唯一索引、在annotations中就是标签的唯一索引。

其次,image_idcategory_id仅在annotations中出现,image_id标识出这个标签是哪张图像的标签,category_id则标识出这个标签对应于哪个类别。

python import导入

本文假设读者已理解sys_path变量和__name__属性,如有不清楚的地方,可以参考旧文:

sys_path变量: python中sys模块必备知识

__name__属性:python中必备模块属性详解

python提供了两种导入模块和包的方法,分别是绝对导入和相对导入。绝对导入是指以sys.path中的路径作为基本路径,从这里开始搜索模块并导入,相对路径是指从当前模块出发,根据其他模块的相对路径关系导入。

绝对导入

我们平时导入python中自带的库(如osmath等)使用的都是绝对导入。与相对导入比起来,绝对导入的适用范围更广。比如说,在python主程序(__name____main__的模块)中不能使用相对导入,只能使用绝对导入,否则将会报错ValueError: Attempted relative import in non-package

导入方法:

1
2
3
4
import <something>
from <where> import <something>
# e.g.
from folder1.file1 import func1

相对导入

相对导入使用前缀点号,与linux的相对命令一样, 一个.表示相对导入从当前目录开始, 两个或更多.表示对当前目录的上级目录的相对导入,第一个点号之后的每个点号代表上一级。

导入方法是:

1
2
3
4
from <where> import <somethings>
# e.g.
from . import function1
from ..folder.file import class1

相对于绝对导入,相对导入不能使用import <something>的形式,因为import <something>要求something是python表达式,而如.module并不符合这一要求,直接运行的话解释器会将其当作错误语法处理。

导入方式

在比较大的项目代码中,进行导入建议分两步处理:

  1. 确保在运行时项目的根目录存在于sys.path中;
  2. 在项目的不同模块包之间使用绝对导入,而在同一个包内部可以更方便的使用相对导入(当然,主程序除外)。

举个例子,存在如下项目:

1
2
3
4
5
6
7
8
# /home/ven/project
project
├──folder1
│ └──file1.py
├──folder2
├──file2.py
│ └──file3.py
└──file4.py

情形1

首先从一个简单的情况开始,我们将file4.py作为主程序。

file4.py直接处于项目的根目录下,旧文 python中sys模块必备知识提到过,在运行时解释器会自动把主程序所在的目录加入sys.path中,在这里恰好将项目的根目录/home/ven/project加了进去。若要导入其他项目模块,直接使用绝对导入即可(主程序不能使用相对路径,还记得吧?)。比如若要调用file1.py中的func1file2.py中的func2。那么导入方式如下:

1
2
3
4
# file4.py

from folder1.file1 import func1
from folder2.file2 import func2

同时如果file2.py中需要调用file3.py中的func3的话,如何导入呢?这时候绝对导入和相对导入都可以,通常相对导入更简便和规范:

1
2
3
4
5
6
# file2.py

# 绝对导入,从/home/ven/project开始
from folder2.file3 import func3
# 相对导入(推荐),从当前位置开始
from .file3 import func3

更进一步的,如果file2.py中需要调用file1.py中的func1_1的话,相对导入和绝对导入也都可以,这时绝对导入更为推荐:

1
2
3
4
5
6
# file2.py

# 绝对导入(推荐)
from folder1.file1 import func1_1
# 相对导入
from ..folder1.file1 import func1_1

情形2

现在考虑一个复杂一点的情况,如果我们的主程序不是file4.py而是file2.py怎么办呢?

开始运行时解释器会自动将file2.py所在目录/home/ven/project/folder2加入sys.path中,而这个目录并不是根目录。这时候就需要手动将根目录加到sys.path中,旧文 python中sys模块必备知识提供了一个简单粗暴的方法:

1
2
3
4
# file2.py

import sys
sys.path.append('/home/ven/project')

这当然是正确的,只是不够严谨。假如说这个项目后来被移动到了另一路径/home/ven/big_project/project下,那么项目将无法正常运行,因为仍然会去/home/ven/project下找模块。

那么有没有更好的添加根目录的方法呢?当然有,这里提供两种:

1
2
3
4
5
6
7
8
# file2.py

# 第一种(推荐)
import os
this_dir = os.path.dirname(__file__)
sys.path.append(os.path.dirname(this_dir))
# 第二种
sys.path.append('../')

第一种方法借助__file__属性(参考旧文:python中必备模块属性详解)将根目录的绝对路径加进sys.path中,第二种则是通过在sys.path加入相对路径使得解释器能够找到根目录(注意这里的相对路径和绝对路径与相对导入和绝对导入是不同的概念)。


掌握python的导入规则对于写大型项目来说是一项基础而重要的技能,否则项目运行中将难免出现各种奇奇怪怪的导入报错,占用大量的时间和精力。本文对python导入规则做了一遍记录和梳理,希望能有助于读者理清相关的知识。

另外,为了方便起见,本文中举的例子都没有建立__init__.py(这在实际项目中可不是好的习惯),所以仅使用“目录”而非“包”的叫法。

python中必备模块属性详解

在python中,每个模块(或脚本)都是可以独立运行的,为了区分不同的模块,每个模块都有对应的系统变量,这些系统变量都以前后加双下划线的形式命令,如__doc____file____name__等。本文将列出几个常用的模块属性。

__name__

__name__是用于标识模块名字的系统变量。

首先将主程序定义为被直接运行的模块,在主程序中变量__name__的值是__main__,在被导入的模块中,变量__name__的值是被导入模块的名字(不含.py后缀),如file1

__name__可以告诉解释器模块是作为程序直接运行还是被导入其他程序中使用,如果模块中有一部分代码,我们希望只在模块作为主程序运行时才被执行,那么__name__将非常有用。

举例来说,模块file1.py内容如下:

1
2
3
4
5
6
# file1.py
def function():
pass
print('file1 is called!')
if __name__ == '__main__':
print('file1 is the main program!')

模块file2.py内容为:

1
2
3
# file2.py
import file1
print('file2 is called!')

那么只有在直接运行file1.py时才会输出file1 is the main program!

1
2
3
4
5
6
$ python file2.py
file1 is called!
file2 is called!
$ python file1.py
file1 is called!
file1 is the main program!

__name__常用于在模块中增加测试代码。

__file__

__file__属性记录模块的绝对路径,这在需要获取模块路径的时候非常常用:

1
2
3
>>> import file1
>>> file1.__file__
/home/ven/file1.py

__all__

__all__定义了模块的公有接口,决定了当使用from <module> import *时哪些内容会被导入。需要注意的是__all__对于from <module> import <member>并没有影响。

依然是以file.py为例,内容为:

1
2
3
4
5
6
# file1.py
a = 1
b = 2
def func():
pass
__all__ = ['a', 'func']

那么在使用from file1 import *时变量b将不会被导入:

1
2
3
4
5
6
7
8
>>> from file1 import *
>>> a
1
>>> b
Traceback (most recent call last):
File "<pyshell#15>", line 1, in <module>
b
NameError: name 'b' is not defined

但是如果我们使用其他方法导入file1.pyb的导入将不受影响:

1
2
3
4
>>> import file1  # fine
>>> file1.b
2
>>> from file1 import b # fine

然而,我们通常不鼓励使用import * 的形式,而一旦使用这种导入形式,那么为了防止因全部导入导致程序混乱,使用__all__管理公有接口是很有必要的。

python中sys模块必备知识

官方解释:https://docs.python.org/3/library/sys.html

顾名思义,sys模块是python中比较底层的库,它提供了一系列与python解释器交互的接口。在大多数情况下我们并不会显式的用到这个模块,但是实际上每个程序的运行都离不开这个模块,了解这个模块做了什么有助于我们理解代码的运行逻辑和规避掉一些潜在的问题。本文将就sys模块几个常用接口的用法做详细介绍。

必备知识——sys.path

在写python项目时,通常会涉及到模块之间的导入,对于比较大的项目来说,经常出现找不到要导入的模块或者导入的方法错误之类的问题。要解决这些问题其实都离不开sys.path,个人认为,每一个使用python的人都应该对sys.path有清晰的理解。

首先从一个问题开始,现在可以想一想,下边这句话python解释器是怎么找到file_b.py的呢?

1
from file_b import function_1

实际上这就是sys.path的作用所在,它是一个指定了模块搜索路径的字符串列表(没错,它不是什么高深莫测的东西,仅仅是一个列表),列表中的每一个字符串都是一个路径,这些路径告诉解释器import的模块要去哪里找,默认是环境变量中的路径。实际使用中我们可以根据项目修改其内容以指定要搜索的路径。

sys.path在程序启动时被初始化, 简单理解的话可以认为此时的sys.path包含两部分内容,第一部分是调用解释器的脚本的路径,运行哪个脚本就把哪个脚本的目录插入sys.path的开头;第二部分是环境变量中的路径。

第二部分没什么好说的,这里举例论证第一部分的路径。如我在sys_module_demo目录下创建模块file1.py,内容为:

1
2
import sys
print('sys.path[0] ==> ', sys.path[0])

保存后运行以下命令:

1
python sys_module_demo/file1.py

那么程序将打印出sys_module_demo目录的绝对路径,比如我的输出是sys.path[0] ==> /home/ven/sys_module_demo。这个路径就是第一部分的路径,其被添加到sys.path中后,同在这个路径下的模块就也被解释器“找到”了。

一个例子

上边我们以单个文件的运行举例,实际上在这种情况下,我们完全不必关注sys.path,因为不涉及到自定义模块之间的相互调用。sys.path最大的作用体现在处理多个处于不同路径下的模块之间的调用问题上,下边将举例说明。

假如说存在如下项目路径:

1
2
3
4
project
├──workwpace
│ └──file1.py
└──file2.py

如果要在file1.py中导入file2.py应该怎么办呢?换句话说file1.py怎么才能知道去哪里找到file2.py呢?

这时候就需要请sys.path出场了,只需要手动地将file2.py的目录加进sys.path中就万事大吉了,两个文件的内容分别如下:

file1.py内容:

1
2
3
4
5
6
7
import sys
sys.path.append('/home/ven/project')
# or relative path
# sys.path.append('../')
import file2

print('file1 invoked!')

file2.py内容:

1
print('file2 invoked!')

此时运行file1.py程序将正常运行并输出:

1
2
file2 invoked!
file1 invoked!

然而...

在大型的项目中使用sys.path有用且必要,但是频繁地在sys.path中添加路径并不是个好习惯,最好的方法是只把项目的根路径添加到sys.path中。

进阶知识

sys还提供了一系列与系统交互的功能,在面对一些特定需求时这些功能将非常有用,这里简单记录两个功能。

获取python版本——sys.version

如果不确定程序要运行在哪个版本的python上,而又想用某些版本特有的语法,那么这个接口就是个非常不错的工具,我们可以尝试一下:

1
2
>>> sys.version
'3.6.2 (v3.6.2:5fd33b5, Jul 8 2017, 04:57:36) [MSC v.1900 64 bit (AMD64)]'

可见我的python版本是3.6。

然而在实际使用中我们更经常用另一个接口sys.version_info来获取版本信息,至于原因我们用一下就知道了:

1
2
>>> sys.version_info
sys.version_info(major=3, minor=6, micro=2, releaselevel='final', serial=0)

sys.version_info把版本信息更精细的整理出来了,而不是像sys.version那样单纯甩给我们一个字符串,这样的输出在程序中非常友好。

假如说我在python2中开发,习惯于将print作为关键字使用,可是如果别人拿走我的程序在python3中运行那不就报错了吗?解决方法就是用sys.version_info

1
2
3
4
if sys.version_info <= (3,0):
print 'this is python3!'
else:
print('this is python2!')

处理命令行参数——sys.argv

在这一点上sys.argv类似于shell编程中的命令行参数,即: sys.argv[0]是运行的文件名本身 sys.argv[1]是第一个参数 sys.argv[2]是第二个参数 以此类推。需要注意的是每一个参数都会被当作字符串

建立一个文件file.py,内容为:

1
2
3
4
5
6
7
8
import sys
print('file name: ', sys.argv[0])
print('first argument: ', sys.argv[1])
print('second argument: ', sys.argv[2])

# string -> int
a, b = int(sys.argv[1]), int(sys.argv[2])
print('sum of arguments: ', a + b)

如下运行file.py

1
python file.py 2 3

将会发现输出结果为:

1
2
3
4
file name: file.py
first argument: 2
second argument: 3
sum of arguments: 5

实际上,这个功能使用的并不多,因为有更好的替代方案argparser。不过,在随手写的小程序中,sys.argv不失为一个小巧而高效的工具。