轉(zhuǎn)載請注明出處
如果學習深度學習在點云處理上的應(yīng)用,那PointNet一定是你躲不開的一個模型。這個模型由斯坦福大學的Charles R. Qi等人在PointNet:Deep Learning on Point Sets for 3D Classification and Segmentation一文中提出。下面我將結(jié)合手中的一些資料談一談我對這篇文章的理解。
簡介
深度學習已經(jīng)成為了計算機視覺領(lǐng)域的一大強有力的工具,尤其在圖像領(lǐng)域,基于卷積神經(jīng)網(wǎng)絡(luò)的深度學習方法已經(jīng)攻占了絕大多數(shù)問題的高點。然而針對無序點云數(shù)據(jù)的深度學習方法研究則進展相對緩慢。這主要是因為點云具有三個特征:無序性、稀疏性、信息量有限。
以往學者用深度學習方法在處理點云時,往往將其轉(zhuǎn)換為特定視角下的深度圖像或者體素(Voxel)等更為規(guī)整的格式以便于定義權(quán)重共享的卷積操作等。
PointNet則允許我們直接輸入點云進行處理。
輸入輸出
輸入為三通道點云數(shù)據(jù),也可以有額外的通道比如顏色、法向量等,輸出整體的類別/每個點所處的部分/每個點的類別。對于目標分類任務(wù),輸出為
個分數(shù),分別對應(yīng)
個可能的類別。對于語義分割任務(wù),輸出
個分數(shù),分別對應(yīng)
個點相對于
各類別的分數(shù)。

點云特征
PointNet網(wǎng)絡(luò)結(jié)構(gòu)的靈感來自于歐式空間里的點云的特點。對于一個歐式空間里的點云,有三個主要特征:
無序性:雖然輸入的點云是有順序的,但是顯然這個順序不應(yīng)當影響結(jié)果。
點之間的交互:每個點不是獨立的,而是與其周圍的一些點共同蘊含了一些信息,因而模型應(yīng)當能夠抓住局部的結(jié)構(gòu)和局部之間的交互。
變換不變性:比如點云整體的旋轉(zhuǎn)和平移不應(yīng)該影響它的分類或者分割
網(wǎng)絡(luò)結(jié)構(gòu)

如圖所示,分類網(wǎng)絡(luò)對于輸入的點云進行輸入變換(input transform)和特征變換(feature transform),隨后通過最大池化將特征整合在一起。分割網(wǎng)絡(luò)則是分類網(wǎng)絡(luò)的延伸,其將整體和局部特征連接在一起出入每個點的分數(shù)。圖片中"mpl"代表"multi-layer perceptron"(多層感知機)。
其中,mlp是通過共享權(quán)重的卷積實現(xiàn)的,第一層卷積核大小是1x3(因為每個點的維度是xyz),之后的每一層卷積核大小都是1x1。即特征提取層只是把每個點連接起來而已。經(jīng)過兩個空間變換網(wǎng)絡(luò)和兩個mlp之后,對每一個點提取1024維特征,經(jīng)過maxpool變成1x1024的全局特征。再經(jīng)過一個mlp(代碼中運用全連接)得到k個score。分類網(wǎng)絡(luò)最后接的loss是softmax。
網(wǎng)絡(luò)特點
針對無序輸入的對稱函數(shù)
為了讓模型具有輸入排列不變性(結(jié)果不受輸入排列順序的影響),一種思路是利用所有可能的排列順序訓練一個RNN。作者在這里采用的思路是使用一個對稱函數(shù),將個向量變?yōu)橐粋€新的、與輸入順序無關(guān)的向量。(例如,
和
是能處理兩個輸入的對稱函數(shù))。
將點云排序是一個可能的對稱函數(shù),不過作者在這里采用一個微型網(wǎng)絡(luò)(T-Net)學習一個獲得變換矩陣的函數(shù),并對初始點云應(yīng)用這個變換矩陣,這一部分被稱為輸入變換。隨后通過一個mlp多層感知機后,再應(yīng)用一次變換矩陣(特征變換)和多層感知機,最后進行一次最大池化。
作者認為以上這個階段學習到的變換函數(shù)是如下圖所表示的函數(shù)和
,保證了模型對特定空間轉(zhuǎn)換的不變性(注意到深度學習實際上是對復(fù)雜函數(shù)的擬合)。
個人的理解是其中作為一個對稱函數(shù),是由最大池化實現(xiàn)的(注意到映射
是n-對稱的);而
是mlp結(jié)構(gòu),代表了一個復(fù)雜函數(shù)(在圖中是將一個3維向量映射成1024維向量的函數(shù))。
(這里變換矩陣的學習過程個人認為有一些玄學,我自己并不能很好地理解其如何獲得旋轉(zhuǎn)不變性。不過深度學習領(lǐng)域有很多無法解釋的東西。感興趣可以參考一下文末的源碼)

整合局部和全局信息
對于點云分割任務(wù),我們需要將局部很全局信息結(jié)合起來。
這里,作者將經(jīng)過特征變換后的信息稱作局部信息,它們是與每一個點緊密相關(guān)的;我們將局部信息和全局信息簡單地連接起來,就得到用于分割的全部信息。
理論分析
除了模型的介紹,作者還引入了兩個相關(guān)的定理:

定理1證明了PointNet的網(wǎng)絡(luò)結(jié)構(gòu)能夠擬合任意的連續(xù)集合函數(shù)。

定理2(a)說明對于任何輸入數(shù)據(jù)集,都存在一個關(guān)鍵集和一個最大集,使得對和之間的任何集合,其網(wǎng)絡(luò)輸出都和一樣。這也就是說,模型對輸入數(shù)據(jù)在有噪聲和有數(shù)據(jù)損壞的情況都是魯棒的。定理2(b)說明了關(guān)鍵集的數(shù)據(jù)多少由maxpooling操作輸出數(shù)據(jù)的維度K給出上界(框架圖中為1024)。個角度來講,PointNet能夠總結(jié)出表示某類物體形狀的關(guān)鍵點,基于這些關(guān)鍵點PointNet能夠判別物體的類別。這樣的能力決定了PointNet對噪聲和數(shù)據(jù)缺失的魯棒性。[引自美團知乎專欄]
下圖給出了一些關(guān)鍵集和最大集的樣例:

后記
我們知道,激光雷達所采集到的數(shù)據(jù)是3D點云。點云的處理應(yīng)用也越來越廣泛,比較常見的應(yīng)用場景是自動駕駛和工業(yè)機器人。
盡管激光雷達(Lidar)的成本依然很高,但考慮到它具有更高的距離測量精度,越來越多的無人駕駛公司開始研究基于激光雷達的無人駕駛方案。據(jù)筆者了解,Momenta、Nullmax于近期(2018年)開始組建激光雷達團隊,而Pony、阿里巴巴AI lab、美團、Waymo等則一直研發(fā)集成激光雷達的無人駕駛方案。盡管Tesla的馬斯克曾經(jīng)對Lidar大為嘲諷,筆者在與VisLab負責人Alberto Broggi的交流時對方也表示Lidar與圖像互補性不是很高(比如他們都會在強光或者雨霧中失效)。我的感覺是,純粹基于圖像的自動駕駛感知方案還不能達到技術(shù)落地的要求,或者無法提供足夠的安全冗余。因而了解一下點云的處理技術(shù)對于自動駕駛從業(yè)者還是很有幫助的,畢竟隨著更多資本的涌入,激光雷達的成本也會有所降低。
此外我也了解到有一些利用激光雷達進行目標識別和定位的機械臂,應(yīng)該也是一個比較火的方向。
參考:
Momenta高級研究員陳亮論文解讀
美團無人配送的知乎專欄:PointNet系列論文解讀
hitrjj的CSDN博客:三維點云網(wǎng)絡(luò)——PointNet論文解讀
github源碼
痛并快樂著呦西的CSDN博客:三維深度學習之pointnet系列詳解
作者的其他相關(guān)文章:
圖像分割:全卷積神經(jīng)網(wǎng)絡(luò)(FCN)詳解
基于視覺的機器人室內(nèi)定位
目標檢測:YOLO和SSD 簡介
論文閱讀:InLoc:基于稠密匹配和視野合成的室內(nèi)定位
論文閱讀:StreetMap-基于向下攝像頭的視覺建圖與定位方案
模型源碼(部分)
輸入變換
def input_transform_net(point_cloud, is_training, bn_decay=None, K=3):
""" Input (XYZ) Transform Net, input is BxNx3 gray image
Return:
Transformation matrix of size 3xK """
batch_size = point_cloud.get_shape()[0].value
num_point = point_cloud.get_shape()[1].value
input_image = tf.expand_dims(point_cloud, -1)
net = tf_util.conv2d(input_image, 64, [1,3],
padding='VALID', stride=[1,1],
bn=True, is_training=is_training,
scope='tconv1', bn_decay=bn_decay)
net = tf_util.conv2d(net, 128, [1,1],
padding='VALID', stride=[1,1],
bn=True, is_training=is_training,
scope='tconv2', bn_decay=bn_decay)
net = tf_util.conv2d(net, 1024, [1,1],
padding='VALID', stride=[1,1],
bn=True, is_training=is_training,
scope='tconv3', bn_decay=bn_decay)
net = tf_util.max_pool2d(net, [num_point,1],
padding='VALID', scope='tmaxpool')
net = tf.reshape(net, [batch_size, -1])
net = tf_util.fully_connected(net, 512, bn=True, is_training=is_training,
scope='tfc1', bn_decay=bn_decay)
net = tf_util.fully_connected(net, 256, bn=True, is_training=is_training,
scope='tfc2', bn_decay=bn_decay)
with tf.variable_scope('transform_XYZ') as sc:
assert(K==3)
weights = tf.get_variable('weights', [256, 3*K],
initializer=tf.constant_initializer(0.0),
dtype=tf.float32)
biases = tf.get_variable('biases', [3*K],
initializer=tf.constant_initializer(0.0),
dtype=tf.float32)
biases += tf.constant([1,0,0,0,1,0,0,0,1], dtype=tf.float32)
transform = tf.matmul(net, weights)
transform = tf.nn.bias_add(transform, biases)
transform = tf.reshape(transform, [batch_size, 3, K])
return transform
主體部分
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.append(BASE_DIR)
sys.path.append(os.path.join(BASE_DIR, '../utils'))
import tf_util
from transform_nets import input_transform_net, feature_transform_net
def placeholder_inputs(batch_size, num_point):
pointclouds_pl = tf.placeholder(tf.float32, shape=(batch_size, num_point, 3))
labels_pl = tf.placeholder(tf.int32, shape=(batch_size))
return pointclouds_pl, labels_pl
def get_model(point_cloud, is_training, bn_decay=None):
""" Classification PointNet, input is BxNx3, output Bx40 """
batch_size = point_cloud.get_shape()[0].value
num_point = point_cloud.get_shape()[1].value
end_points = {}
with tf.variable_scope('transform_net1') as sc:
transform = input_transform_net(point_cloud, is_training, bn_decay, K=3)
point_cloud_transformed = tf.matmul(point_cloud, transform)
input_image = tf.expand_dims(point_cloud_transformed, -1)
net = tf_util.conv2d(input_image, 64, [1,3],
padding='VALID', stride=[1,1],
bn=True, is_training=is_training,
scope='conv1', bn_decay=bn_decay)
net = tf_util.conv2d(net, 64, [1,1],
padding='VALID', stride=[1,1],
bn=True, is_training=is_training,
scope='conv2', bn_decay=bn_decay)
with tf.variable_scope('transform_net2') as sc:
transform = feature_transform_net(net, is_training, bn_decay, K=64)
end_points['transform'] = transform
net_transformed = tf.matmul(tf.squeeze(net, axis=[2]), transform)
net_transformed = tf.expand_dims(net_transformed, [2])
net = tf_util.conv2d(net_transformed, 64, [1,1],
padding='VALID', stride=[1,1],
bn=True, is_training=is_training,
scope='conv3', bn_decay=bn_decay)
net = tf_util.conv2d(net, 128, [1,1],
padding='VALID', stride=[1,1],
bn=True, is_training=is_training,
scope='conv4', bn_decay=bn_decay)
net = tf_util.conv2d(net, 1024, [1,1],
padding='VALID', stride=[1,1],
bn=True, is_training=is_training,
scope='conv5', bn_decay=bn_decay)
# Symmetric function: max pooling
net = tf_util.max_pool2d(net, [num_point,1],
padding='VALID', scope='maxpool')
net = tf.reshape(net, [batch_size, -1])
net = tf_util.fully_connected(net, 512, bn=True, is_training=is_training,
scope='fc1', bn_decay=bn_decay)
net = tf_util.dropout(net, keep_prob=0.7, is_training=is_training,
scope='dp1')
net = tf_util.fully_connected(net, 256, bn=True, is_training=is_training,
scope='fc2', bn_decay=bn_decay)
net = tf_util.dropout(net, keep_prob=0.7, is_training=is_training,
scope='dp2')
net = tf_util.fully_connected(net, 40, activation_fn=None, scope='fc3')
return net, end_points
def get_loss(pred, label, end_points, reg_weight=0.001):
""" pred: B*NUM_CLASSES,
label: B, """
loss = tf.nn.sparse_softmax_cross_entropy_with_logits(logits=pred, labels=label)
classify_loss = tf.reduce_mean(loss)
tf.summary.scalar('classify loss', classify_loss)
# Enforce the transformation as orthogonal matrix
transform = end_points['transform'] # BxKxK
K = transform.get_shape()[1].value
mat_diff = tf.matmul(transform, tf.transpose(transform, perm=[0,2,1]))
mat_diff -= tf.constant(np.eye(K), dtype=tf.float32)
mat_diff_loss = tf.nn.l2_loss(mat_diff)
tf.summary.scalar('mat loss', mat_diff_loss)
return classify_loss + mat_diff_loss * reg_weight
if __name__=='__main__':
with tf.Graph().as_default():
inputs = tf.zeros((32,1024,3))
outputs = get_model(inputs, tf.constant(True))
print(outputs)