动作识别在视频理解中发挥着重要作用,其中,人体骨骼动力学为人体动作识别传递了重要信息。基于骨架的动作识别近年来一直是计算机视觉和深度学习领域中的一个研究热点,而ST-GCN属于是一篇开山之作,许多地方做的比较基础所以有很多可以提升的地方,之后的2S-AGCN还有ST-GCN++等都是基于它的改进,不过对于想要学习基于骨架行为识别的初学者来说,ST-GCN这篇论文的学习价值是很高的。
论文地址:
Spatial Temporal Graph Convolutional Networks for Skeleton-Based Action Recognition
早期的Skeleton-Based动作识别模型:
- 早期使用骨架进行动作识别的方法只是简单的利用各个时间帧的关节(joint)坐标形成特征向量,然后对其进行时序分析。这没有显示地考虑到关节之间的空间连接,但其实这种空间关系是理解人类行为的关键。
- 传统的骨架建模通常依赖于手工标注数据(hand-crafted)和规定好的遍历规则(traversal rules), 这会导致模型泛化能力太弱。
因此,ST-GCN作者提出了一种同时针对时间维度和空间维度的卷积来处理骨架数据。同时模型也消除了对hand-crafted和traversal rules的需要,增强了模型的泛化能力,便于被推广到不同的环境中应用。
Skeleton 骨架 -> Graph 图
一副骨架可以抽象为两种元素组成——关节点(joint)和骨骼(bone)。关节点的作用是连接两根相邻的骨骼。因此,我们可以把骨架简化为一个由点(nodes)和边(edges)所构成的图(Graph)。点(nodes)对应骨架中的关节点(joint),边(edges)对应骨架中的骨骼(bone)。把一副骨架放在三维欧几里得空间中,点的属性就是其对应的三维空间中的坐标(x, y, z),边就是三维空间中的一条线段。如果是二维空间,坐标就是(x, y)。
OpenPose 人体姿态估计算法
基于骨骼的数据可以从运动捕捉设备中获得,也可以通过姿态估计(Pose Estimation)算法预测获得。在这里我们主要以OpenPose为例介绍,OpenPose是一个标注人体关节(颈部、肩膀、肘部等等),连接成骨骼,进而估计人体姿态的算法。它的输入是RGB image或Video sequence,输出是人体关节点位置坐标(2D)和置信度(x, y, confidence)。在行为识别算法中,OpenPose总共估计人体18个关节点,关节点顺序如图2所示。一个batch的视频,OpenPose的输出是一个5维矩阵(N,C,T,V,M),这也是ST-GCN的输入:
- N :视频的数量 - Batch Size
- C :关节的特征,通常一个关节包含3个特征:x,y,acc
- T :选取的关键帧的数量
- V :关节的数量,OpenPose中是18个关节,Coco是17个,NTU-RGB+D一般为25个
- M :一帧中的人数
ST-GCN Pipeline
图3展示了ST-GCN的大概流程,首先输入动作视频(Input Video),然后通过姿态估计(Pose Estimation)算法(如OpenPose)获得每一帧人体骨骼节点的2维坐标数据。通常数据是一组帧,每一帧都有一组关节坐标。根据给定的关节连接信息和顺序,我们可以构造一个以关节坐标为图节点,人体结构的连接和时间帧的连接为边的时空图(Spatial-Temporal Graph)来作为ST-GCN的输入。对输入数据进行多层时空图卷积运算,在图(graph)上生成更高层次的特征图(feature maps)。然后输入到SoftMax分类器进行动作分类(Action Classification)。整个模型采用端对端反向传播的方式进行训练。下面我们来看一下每部分是如何实现的。
Skeleton Graph Construction
对于每一帧图像,我们提取人的骨架节点作为图的结点,以骨架的自然连接作为结点与结点之间的边,这就构建出了一帧的图。同时,我们将每个节点的在时间帧上的对应节点也用边连接起来,这就构成了一个包含时间和空间信息的图。
节点 (Node)
关节 (在每一帧当中位置可能变化),所以每一帧都有一组关节特征向量,代表某一帧的某个关节的坐标
边 (Edge)
- Spatial edges ( 蓝色 ):依照人体在生理上的骨架连接构成
- Temporal edges ( 绿色 ):相同节点在不同帧(不同时间)上的连接
Spatial Graph Convolutional Neural Network
作者在介绍空间图卷积网络的时候,为了让读者更清楚的了解GCN中卷积的概念,和CNN做了类比。图5是CNN的卷积公式,其中p是抽样函数(sampling function), p函数的实质就是定位在图像窗口上的邻居位置,意思就是取出p(x,h,w)这个位置的数据。CNN中抽样就是将以卷积核大小的原始数据按行列顺序依次拿出。w函数(weight function)是一个权重向量,全局共享参数,对每一个窗口进行加权并加到中心结点x中,依据节点的相对位置来给出权重参数。CNN中每一个抽样得到的数据有不同的权值,其值就是卷积和这个位置的值。
那么在我们的GCN上,抽样和加权是如何做的呢?我们需要定义好抽样函数,然后对于每一类位置抽样出来的邻居数据,定义和分配权值函数。与抽样函数相比,加权函数的定义要复杂一些,我们知道在二维卷积中,当我们已经确定一个中心结点后,他的邻居节点的相对位置是确定的,领域内的像素可以有固定的空间顺序,然后加权函数可以通过根据空间顺序索引来实现。但是对于Graph来说,没有这样的隐式排列,所以作者的想法就是将邻居节点编号分类,将每一个邻居节点分到K个子集中并且依序编号。每一个子集都有一个标签,而不是每一个邻居节点都有一个唯一的标签。这样一来,一个子集的节点,也就是一组节点对应一个权重。知道了抽样函数和加权函数的定义和概念,那么如何将关节,也就是我们的节点分组呢?论文中介绍了三种不同的分区策略(Partition Strategies):
1. 单标签划分策略 (Uni-labeling)
最简单最直接的方法,就是仅仅只划分一个子集,K=1,子集包括中心结点以及其所有的邻居,但是这种划分方法最后得到的结果不好,会失去局部的特点属性。
2. 距离分区 (Distance Partitioning)
这种策略是依据相距中心结点的距离来做的分类,我们会分为两类,K=2,第一类是中心结点自己本身,第二类是距离中心结点为1的结点。
3. 空间构型划分(Spatial Configuration Partitioning)
这是一种基于对关节点分组的分类的策略。我们可以人为的定义3个子集,K=3,第一类是中心结点本身,第二类是邻居节点到重心的距离小于中心结点(根节点)到重心的距离的节点,第三类表示其他情况。这样可以抽象的把人的运动分为三种不同运动状态(静止,离心运动和向心运动)。
在代码中作者使用的第三种策略,因为它表现的性能最好。根据选定的分区策略把节点分为3组之后,我们就可以构建我们的邻接矩阵A。如果对GCN有了解,就应该知道邻接矩阵是一个很重要的输入,它可以将图的连通性形象的表示出来。我们也可以把它看作是图卷积中的卷积核,具体的内容可以读这篇文章进行学习:
如何理解 Graph Convolutional Network (GCN)?
ST-GCN实现步骤
了解了如何构造时空图,如何划分节点进行抽样然后定义加权函数之后,我们现在来看一下ST-GCN的实现步骤:
- 引入一个可学习的权重矩阵(与邻接矩阵等大小)与邻接矩阵按位相乘。该权重矩阵叫做“Learnable edge importance weight”,用来赋予邻接矩阵中重要边(节点)较大的权重且抑制非重要边(节点)的权重。每个edge_importance初始值为1,且是Parameter类型,梯度可以回流从而进行优化。
- 将加权后的邻接矩阵A与输入X送至GCN中进行运算,实现空间维度信息的聚合
- 利用TCN网络(实际上是一种普通的CNN)实现时间维度信息的聚合
- 引入了残差结构计算获得Res,与TCN的输出按位相加
TCN - 学习时间中关节变化的局部特征
ST-GCN中把网络分为GCN部分和TCN部分,GCN部分运用到的是图卷积网络的原理,而TCN部分其实依然是以CNN为基础实现的。类比图像的卷积,图像的通道数在这里对应关节的特征数(3: x, y, 置信度),图像的宽对应关键帧数,图像的高对应关节数。卷积核的大小为 temporal_kernel_size x 1,即每次完成 1 个节点,temporal_kernel_size 个关键帧的卷积。stride为 1,即每次移动 1 帧,完成 1 个节点后进行下 1 个节点的卷积。图7形象化的展示了这个过程。
ST-GCN源码解读
代码GitHub地址: https://github.com/kennymckormick/pyskl
ST-GCN的核心代码有3个文件,在net文件夹下,分别为graph.py
, tgcn.py
, st-gcn.py
。其中graph.py
中包含邻接矩阵的建立和结点分组策略、st-gcn.py
包含整个网络部分的结构和前向传播方法、tgcn.py
主要是空间域卷积的结构和前向传播方法。
graph.py
类Graph的构造函数使用了self.get_edge
、self.hop_dis
、self.get_adjacency
def __init__(self,
layout='openpose',
strategy='uniform',
max_hop=1,
dilation=1):
self.max_hop = max_hop
self.dilation = dilation
self.get_edge(layout)
self.hop_dis = get_hop_distance(
self.num_node, self.edge, max_hop=max_hop)
self.get_adjacency(strategy)
1. self.get_edge()
通过这个函数我们可以获得图(Graph)的边信息。这里我们以OpenPose为例,不解释layout为其他的情况。可以看到当我们选定OpenPose之后,num_node
被赋值为18,center
被赋值为1(脖子为身体重心)。neighbor_link
是我们定义的图的连接,例如(4, 3)就是节点4(右手腕)和节点3(右手肘)要相连。
def get_edge(self, layout):
if layout == 'openpose':
self.num_node = 18
self_link = [(i, i) for i in range(self.num_node)]
neighbor_link = [(4, 3), (3, 2), (7, 6), (6, 5), (13, 12), (12,
11),
(10, 9), (9, 8), (11, 5), (8, 2), (5, 1), (2, 1),
(0, 1), (15, 0), (14, 0), (17, 15), (16, 14)]
self.edge = self_link + neighbor_link
self.center = 1
elif layout == 'ntu-rgb+d':
elif layout == 'ntu_edge':
else:
raise ValueError("Do Not Exist This Layout.")
2. self.hop_dis()
通过这个函数我们获得了一个代表图节点之间距离的矩阵(0代表自环,1代表相连,inf代表不相连)。后面需要用到这个矩阵来进行节点分组(分区策略,会把节点分为三组)。
def get_hop_distance(num_node, edge, max_hop=1):
A = np.zeros((num_node, num_node))
for i, j in edge:
A[j, i] = 1
A[i, j] = 1
# compute hop steps
hop_dis = np.zeros((num_node, num_node)) + np.inf
transfer_mat = [np.linalg.matrix_power(A, d) for d in range(max_hop + 1)]
arrive_mat = (np.stack(transfer_mat) > 0)
for d in range(max_hop, -1, -1):
hop_dis[arrive_mat[d]] = d
return hop_dis
3. self.get_adjacency()
通过这个函数我们可以获得一个(3,18,18)的权值分组A矩阵。可以看到代码中首先初始了三个矩阵: a_root
、a_close
、a_further
, 然后通过对比根节点到重心的距离和邻居节点到重心的距离: self.hop_dis[j, self.center] == ? > ? < self.hop_dis[i, self.center]
把节点分为三组,然后再把邻接矩阵分为三组。 需要特别指出的是,这里的normalize_adjacency已经是归一化之后的A了。
def get_adjacency(self, strategy):
valid_hop = range(0, self.max_hop + 1, self.dilation)
adjacency = np.zeros((self.num_node, self.num_node))
for hop in valid_hop:
adjacency[self.hop_dis == hop] = 1
normalize_adjacency = normalize_digraph(adjacency)
# 三种策略,我们使用第三种,其他两种在这里忽略
if strategy == 'uniform':
elif strategy == 'distance':
elif strategy == 'spatial':
A = []
for hop in valid_hop:
a_root = np.zeros((self.num_node, self.num_node))
a_close = np.zeros((self.num_node, self.num_node))
a_further = np.zeros((self.num_node, self.num_node))
for i in range(self.num_node):
for j in range(self.num_node):
if self.hop_dis[j, i] == hop:
if self.hop_dis[j, self.center] == self.hop_dis[
i, self.center]:
a_root[j, i] = normalize_adjacency[j, I]
elif self.hop_dis[j, self.
center] > self.hop_dis[i, self.
center]:
a_close[j, i] = normalize_adjacency[j, I]
else:
a_further[j, i] = normalize_adjacency[j, I]
if hop == 0:
A.append(a_root)
else:
A.append(a_root + a_close)
A.append(a_further)
A = np.stack(A)
self.A = A
else:
raise ValueError("Do Not Exist This Strategy")
st-gcn.py
1. 网络的输入
在前面提到过,整个网络的输入是一个(N, C, T, V, M)的5维tensor。所以在进行2维卷积(n, c, h, w)的时候需要将 N 与 M 合并起来形成(N * M, C, T, V)换成这样的格式就可以与2维卷积完全类比起来。CNN中核的两维对应的是(h, w),而st-gcn的核对应的是(T, V)。
N, C, T, V, M = x.size()
x = x.permute(0, 4, 3, 1, 2).contiguous()
x = x.view(N * M, V * C, T)
x = self.data_bn(x)
x = x.view(N, M, V, C, T)
x = x.permute(0, 1, 3, 4, 2).contiguous()
x = x.view(N * M, C, T, V)
2. 模型 Model
可以看到网络的结构是:
- 一个输入层的
batchNorm
(接受的通道数是in_channels * A.size(1) (3 * 18) - 第二部分由9层
st_gcn
层构成(第一层的st_gcn不算作stgcn模块中,所以一共有9层) - 最后加一层全连接层
self.fcn
self.data_bn = nn.BatchNorm1d(in_channels * A.size(1))
self.st_gcn_networks = nn.ModuleList((
st_gcn(in_channels, 64, kernel_size, 1, residual=False, **kwargs0),
st_gcn(64, 64, kernel_size, 1, **kwargs),
st_gcn(64, 64, kernel_size, 1, **kwargs),
st_gcn(64, 64, kernel_size, 1, **kwargs),
st_gcn(64, 128, kernel_size, 2, **kwargs),
st_gcn(128, 128, kernel_size, 1, **kwargs),
st_gcn(128, 128, kernel_size, 1, **kwargs),
st_gcn(128, 256, kernel_size, 2, **kwargs),
st_gcn(256, 256, kernel_size, 1, **kwargs),
st_gcn(256, 256, kernel_size, 1, **kwargs),
))
# initialize parameters for edge importance weighting
if edge_importance_weighting:
self.edge_importance = nn.ParameterList([
nn.Parameter(torch.ones(self.A.size()))
for i in self.st_gcn_networks
])
else:
self.edge_importance = [1] * len(self.st_gcn_networks)
# fcn for prediction
self.fcn = nn.Conv2d(256, num_class, kernel_size=1)
每一层ST-GCN是这样的,作者定义了一个st_gcn
类,可以看到其中包含了两个主要的模块
- 对于spatial空间的
self.gcn
:ConvTemporalGraphical
模块 - 对于temporal空间的
sef.tcn
模块
class st_gcn(nn.Module):
...
self.gcn = ConvTemporalGraphical(in_channels, out_channels,
kernel_size[1])
self.tcn = nn.Sequential(
nn.BatchNorm2d(out_channels),
nn.ReLU(inplace=True),
nn.Conv2d(
out_channels,
out_channels,
(kernel_size[0], 1),
(stride, 1),
padding,
),
nn.BatchNorm2d(out_channels),
nn.Dropout(dropout, inplace=True),
)
if not residual:
self.residual = lambda x: 0
elif (in_channels == out_channels) and (stride == 1):
self.residual = lambda x: x
else:
self.residual = nn.Sequential(
nn.Conv2d(
in_channels,
out_channels,
kernel_size=1,
stride=(stride, 1)),
nn.BatchNorm2d(out_channels),
)
self.relu = nn.ReLU(inplace=True)
...
3. TCN
在这里我们先介绍TCN模块self.tcn
,因为它相较于GCN好理解一点。该模块让网络在时域中进行特征的提取,类似与LSTM,GCN的输出是一个(n,c,t,w)的blob,在TCN中可以简单的理解为和CNN的输入格式一样。TCN用(temporal_kernel_size, 1)的卷积核对t维度进行卷积运算。这部分就是正常的卷积操作,对于同一个节点在不同t下的特征的卷积。一个TCN层由 BN 模块、Conv2d 模块、BN 模块、Dropout 模块组成。
self.tcn = nn.Sequential(
nn.BatchNorm2d(out_channels),
nn.ReLU(inplace=True),
nn.Conv2d(
out_channels,
out_channels,
(kernel_size[0], 1),
(stride, 1),
padding,
),
nn.BatchNorm2d(out_channels),
nn.Dropout(dropout, inplace=True),
)
tgcn.py
tgcn.py主要包含空间域卷积的结构和前向传播方法, 也就是GCN模块。这是ST-GCN中最重要也是最难理解的部分。
class ConvTemporalGraphical(nn.Module):
def __init__(self,
in_channels,
out_channels,
kernel_size,
t_kernel_size=1,
t_stride=1,
t_padding=0,
t_dilation=1,
bias=True):
super().__init__()
self.kernel_size = kernel_size
self.conv = nn.Conv2d(
in_channels,
out_channels * kernel_size,
kernel_size=(t_kernel_size, 1),
padding=(t_padding, 0),
stride=(t_stride, 1),
dilation=(t_dilation, 1),
bias=bias)
def forward(self, x, A):
assert A.size(0) == self.kernel_size
x = self.conv(x)
n, kc, t, v = x.size()
x = x.view(n, self.kernel_size, kc//self.kernel_size, t, v)
x = torch.einsum('nkctv,kvw->nctw', (x, A))
return x.contiguous(), A
在这段代码中,最重要的两句代码是:
x = self.conv(x)
x = torch.einsum(‘nkctv,kvw->nctw’, (x, A))
我们先来理解第一句做的事情。假设batch_size = 1,kernel_size = 3的情况,这样我们的输入就是(c,t,v)的特征blob。这里的self.conv的卷积核大小是(1, 1),是一个1x1的卷积层。在第一层,in_channels = 3,conv1_1参数是(in_channels, kernel_size * out_channels), 所以在内部会有3(kernel_size)组blob,每一个blob是由kernel_size × out_channels个不同的conv1_1来实现卷积操作的,可以抽象地将这么多个卷积核分成3组,即图10中的3行(深棕浅棕、深蓝浅蓝、粉黄三组),每一行有out_channels个conv1_1卷积核。最终得到的卷积后的特征也分为3(kernel_size)组。所以很明显,这一层1x1卷积的目的就是为了改变通道数,kernel_size = 3, 相当于把输入用了3组不同的卷积核,每组卷积下来可以得到输出通道为2的三组特征图。这一层1x1卷积只是把特征升维,且按自己分了多少组加倍。
然后我们来看第二句代码
x = torch.einsum(‘nkctv,kvw->nctw’, (x, A))
这里使用了爱因斯坦求和约定,这个公式可以理解为根据邻接矩阵A中的邻接关系做了一次邻接节点间的特征融合,输出就变回了(N*M, C, T, V)的格式。需要注意的是,einsum中是不在意变量顺序的,所以不用纠结x在前还是A在前,结果是一样的。
到这里ST-GCN的主要内容就讲完了,在开头我们提到ST-GCN++是基于ST-GCN的改进,那么具体ST-GCN++改进了哪些内容,在这里我简单的介绍一下。
ST-GCN++改进点:
- 空间模块
- 在ST-GCN中,预定义的稀疏系数矩阵用于融合一个人不同关节的特征,而系数矩阵则由预定义的关节拓扑得来。同时ST-GCN还用一组可学习的权重对系数矩阵中的每个元素重新加权。而在ST-GCN++中,只使用了预定义的关节拓扑来初始化系数矩阵,在训练过程中使用梯度下降迭代更新系数矩阵,没有任何约束。也就是说,ST-GCN++没有使用空间构型划分来分组节点从而赋予权重。只是使用了单标签划分策略。
- 在空间模块中加入了残差链接 (Residual link),进一步提高了空间建模能力
-
时间模块
ST-GCN在kernel size为9的时间维度上使用1D卷积进行时间建模,大的kernel覆盖的时间感受野也更广。但是ST-GCN++的作者认为这种设计缺乏灵活性,导致计算和参数冗余。ST-GCN++使用了多分支TCN来代替这种单分支设计。多分支由6个分支组成:- 1个‘1x1’ Con 分支
- 1个Max-Pooling分支
- 4个Temporal 1D Con 分支,temporal kernel size = 3,dilations从1到4
首先用‘1x1’ Conv 变换特征,并将特征分为六组,每组通道相同。然后用单个分支处理每个feature group。6个输出连接在一起,通过另一个‘1x1’卷积处理,形成多支路TCN输出。ST-GCN++的这种TCN设计不仅提高了时间建模能力,而且由于减少了每个分支的通道宽,节省了计算成本和参数。
参考文章:
[1] St-gcn 动作识别 理论+源码分析(Pytorch)
[2] 如何评价ST-GCN动作识别算法?
[3] 图卷积在基于骨架的动作识别中的应用
[4] Action Detection ST-GCN论文与代码解析
[5] ST-GCN的学习之路(二)源码解读 (Pytorch版)