作者:馮牮
前言
- 本文不是神經網絡或機器學習的入門教學,而是通過一個真實的產品案例,展示了在手機客戶端上運行一個神經網絡的關鍵技術點
- 在卷積神經網絡適用的領域里,已經出現了一些很經典的圖像分類網絡,比如 VGG16/VGG19,Inception v1-v4 Net,ResNet 等,這些分類網絡通常又都可以作為其他算法中的基礎網絡結構,尤其是 VGG 網絡,被很多其他的算法借鑒,本文也會使用 VGG16 的基礎網絡結構,但是不會對 VGG 網絡做詳細的入門教學
- 雖然本文不是神經網絡技術的入門教程,但是仍然會給出一系列的相關入門教程和技術文檔的鏈接,有助於進一步理解本文的內容
- 具體使用到的神經網絡算法,只是本文的一個組成部分,除此之外,本文還介紹了如何裁剪 TensorFlow 靜態庫以便於在手機端運行,如何准備訓練樣本圖片,以及訓練神經網絡時的各種技巧等等
需求是什么
需求很容易描述清楚,如上圖,就是在一張圖里,把矩形形狀的文檔的四個頂點的坐標找出來。
傳統的技術方案
Google 搜索 opencv scan document,是可以找到好幾篇相關的教程的,這些教程里面的技術手段,也都大同小異,關鍵步驟就是調用 OpenCV 里面的兩個函數,cv2.Canny() 和 cv2.findContours()。
看上去很容易就能實現出來,但是真實情況是,這些教程,僅僅是個 demo 演示而已,用來演示的圖片,都是最理想的簡單情況,真實的場景圖片會比這個復雜的多,會有各種干擾因素,調用 canny 函數得到的邊緣檢測結果,也會比 demo 中的情況凌亂的多,比如會檢測出很多各種長短的線段,或者是文檔的邊緣線被截斷成了好幾條短的線段,線段之間還存在距離不等的空隙。另外,findContours 函數也只能檢測閉合的多邊形的頂點,但是並不能確保這個多邊形就是一個合理的矩形。因此在我們的第一版技術方案中,對這兩個關鍵步驟,進行了大量的改進和調優,概括起來就是:
- 改進 canny 算法的效果,增加額外的步驟,得到效果更好的邊緣檢測圖
- 針對 canny 步驟得到的邊緣圖,建立一套數學算法,從邊緣圖中尋找出一個合理的矩形區域
傳統技術方案的難度和局限性
- canny 算法的檢測效果,依賴於幾個閥值參數,這些閥值參數的選擇,通常都是人為設置的經驗值,在改進的過程中,引入額外的步驟后,通常又會引入一些新的閥值參數,同樣,也是依賴於調試結果設置的經驗值。整體來看,這些閥值參數的個數,不能特別的多,因為一旦太多了,就很難依賴經驗值進行設置,另外,雖然有這些閥值參數,但是最終的參數只是一組或少數幾組固定的組合,所以算法的魯棒性又會打折扣,很容易遇到邊緣檢測效果不理想的場景
- 在邊緣圖上建立的數學模型很復雜,代碼實現難度大,而且也會遇到算法無能為力的場景
下面這張圖表,能夠很好的說明上面列出的這兩個問題:
這張圖表的第一列是輸入的 image,最后的三列(先不用看這張圖表的第二列),是用三組不同閥值參數調用 canny 函數和額外的函數后得到的輸出 image,可以看到,邊緣檢測的效果,並不總是很理想的,有些場景中,矩形的邊,出現了很嚴重的斷裂,有些邊,甚至被完全擦除掉了,而另一些場景中,又會檢測出很多干擾性質的長短邊。可想而知,想用一個數學模型,適應這么不規則的邊緣圖,會是多么困難的一件事情。
思考如何改善
在第一版的技術方案中,負責的同學花費了大量的精力進行各種調優,終於取得了還不錯的效果,但是,就像前面描述的那樣,還是會遇到檢測不出來的場景。在第一版技術方案中,遇到這種情況的時候,采用的做法是針對這些不能檢測的場景,人工進行分析和調試,調整已有的一組閥值參數和算法,可能還需要加入一些其他的算法流程(可能還會引入新的一些閥值參數),然后再整合到原有的代碼邏輯中。經過若干輪這樣的調整后,我們發現,已經進入一個瓶頸,按照這種手段,很難進一步提高檢測效果了。
既然傳統的算法手段已經到極限了,那不如試試機器學習/神經網絡。
無效的神經網絡算法
end-to-end 直接擬合
首先想到的,就是仿照人臉對齊(face alignment)的思路,構建一個端到端(end-to-end)的網絡,直接回歸擬合,也就是讓這個神經網絡直接輸出 4 個頂點的坐標,但是,經過嘗試后發現,根本擬合不出來。后來仔細琢磨了一下,覺得不能直接擬合也是對的,因為:
- 除了分類(classification)問題之外,所有的需求看上去都像是一個回歸(regression)問題,如果回歸是萬能的,學術界為啥還要去搞其他各種各樣的網絡模型
- face alignment 之所以可以用回歸網絡得到很好的擬合效果,是因為在輸入 image 上先做了 bounding box 檢測,縮小了人臉圖像范圍后,才做的 regression
- 人臉上的關鍵特征點,具有特別明顯的統計學特征,所以 regression 可以發揮作用
- 在需要更高檢測精度的場景中,其實也是用到了更復雜的網絡模型來解決 face alignment 問題的
YOLO && FCN
后來還嘗試過用 YOLO 網絡做 Object Detection,用 FCN 網絡做像素級的 Semantic Segmentation,但是結果都很不理想,比如:
- 達不到文檔檢測功能想要的精確度
- 網絡結構復雜,運算量大,在手機上無法做到實時檢測
有效的神經網絡算法
前面嘗試的幾種神經網絡算法,都不能得到想要的效果,后來換了一種思路,既然傳統的技術手段里包含了兩個關鍵的步驟,那能不能用神經網絡來分別改善這兩個步驟呢,經過分析發現,可以嘗試用神經網絡來替換 canny 算法,也就是用神經網絡來對圖像中的矩形區域進行邊緣檢測,只要這個邊緣檢測能夠去除更多的干擾因素,那第二個步驟里面的算法也就可以變得更簡單了。
神經網絡的輸入和輸出
按照這種思路,對於神經網絡部分,現在的需求變成了上圖所示的樣子。
HED(Holistically-Nested Edge Detection) 網絡
邊緣檢測這種需求,在圖像處理領域里面,通常叫做 Edge Detection 或 Contour Detection,按照這個思路,找到了 Holistically-Nested Edge Detection 網絡模型。
HED 網絡模型是在 VGG16 網絡結構的基礎上設計出來的,所以有必要先看看 VGG16。
上圖是 VGG16 的原理圖,為了方便從 VGG16 過渡到 HED,我們先把 VGG16 變成下面這種示意圖:
在上面這個示意圖里,用不同的顏色區分了 VGG16 的不同組成部分。
從示意圖上可以看到,綠色代表的卷積層和紅色代表的池化層,可以很明顯的划分出五組,上圖用紫色線條框出來的就是其中的第三組。
HED 網絡要使用的就是 VGG16 網絡里面的這五組,后面部分的 fully connected 層和 softmax 層,都是不需要的,另外,第五組的池化層(紅色)也是不需要的。
去掉不需要的部分后,就得到上圖這樣的網絡結構,因為有池化層的作用,從第二組開始,每一組的輸入 image 的長寬值,都是前一組的輸入 image 的長寬值的一半。
HED 網絡是一種多尺度多融合(multi-scale and multi-level feature learning)的網絡結構,所謂的多尺度,就是如上圖所示,把 VGG16 的每一組的最后一個卷積層(綠色部分)的輸出取出來,因為每一組得到的 image 的長寬尺寸是不一樣的,所以這里還需要用轉置卷積(transposed convolution)/反卷積(deconv)對每一組得到的 image 再做一遍運算,從效果上看,相當於把第二至五組得到的 image 的長寬尺寸分別擴大 2 至 16 倍,這樣在每個尺度(VGG16 的每一組就是一個尺度)上得到的 image,都是相同的大小了。
把每一個尺度上得到的相同大小的 image,再融合到一起,這樣就得到了最終的輸出 image,也就是具有邊緣檢測效果的 image。
基於 TensorFlow 編寫的 HED 網絡結構代碼如下:
def hed_net(inputs, batch_size):
# ref https://github.com/s9xie/hed/blob/master/examples/hed/train_val.prototxt
with tf.variable_scope('hed', 'hed', [inputs]):
with slim.arg_scope([slim.conv2d, slim.fully_connected],
activation_fn=tf.nn.relu,
weights_initializer=tf.truncated_normal_initializer(0.0, 0.01),
weights_regularizer=slim.l2_regularizer(0.0005)):
# vgg16 conv && max_pool layers
net = slim.repeat(inputs, 2, slim.conv2d, 12, [3, 3], scope='conv1')
dsn1 = net
net = slim.max_pool2d(net, [2, 2], scope='pool1')
net = slim.repeat(net, 2, slim.conv2d, 24, [3, 3], scope='conv2')
dsn2 = net
net = slim.max_pool2d(net, [2, 2], scope='pool2')
net = slim.repeat(net, 3, slim.conv2d, 48, [3, 3], scope='conv3')
dsn3 = net
net = slim.max_pool2d(net, [2, 2], scope='pool3')
net = slim.repeat(net, 3, slim.conv2d, 96, [3, 3], scope='conv4')
dsn4 = net
net = slim.max_pool2d(net, [2, 2], scope='pool4')
net = slim.repeat(net, 3, slim.conv2d, 192, [3, 3], scope='conv5')
dsn5 = net
# net = slim.max_pool2d(net, [2, 2], scope='pool5') # no need this pool layer
# dsn layers
dsn1 = slim.conv2d(dsn1, 1, [1, 1], scope='dsn1')
# no need deconv for dsn1
dsn2 = slim.conv2d(dsn2, 1, [1, 1], scope='dsn2')
deconv_shape = tf.pack([batch_size, const.image_height, const.image_width, 1])
dsn2 = deconv_mobile_version(dsn2, 2, deconv_shape) # deconv_mobile_version can work on mobile
dsn3 = slim.conv2d(dsn3, 1, [1, 1], scope='dsn3')
deconv_shape = tf.pack([batch_size, const.image_height, const.image_width, 1])
dsn3 = deconv_mobile_version(dsn3, 4, deconv_shape)
dsn4 = slim.conv2d(dsn4, 1, [1, 1], scope='dsn4')
deconv_shape = tf.pack([batch_size, const.image_height, const.image_width, 1])
dsn4 = deconv_mobile_version(dsn4, 8, deconv_shape)
dsn5 = slim.conv2d(dsn5, 1, [1, 1], scope='dsn5')
deconv_shape = tf.pack([batch_size, const.image_height, const.image_width, 1])
dsn5 = deconv_mobile_version(dsn5, 16, deconv_shape)
# dsn fuse
dsn_fuse = tf.concat(3, [dsn1, dsn2, dsn3, dsn4, dsn5])
dsn_fuse = tf.reshape(dsn_fuse, [batch_size, const.image_height, const.image_width, 5]) #without this, will get error: ValueError: Number of in_channels must be known.
dsn_fuse = slim.conv2d(dsn_fuse, 1, [1, 1], scope='dsn_fuse')
return dsn_fuse, dsn1, dsn2, dsn3, dsn4, dsn5
訓練網絡
cost 函數
論文給出的 HED 網絡是一個通用的邊緣檢測網絡,按照論文的描述,每一個尺度上得到的 image,都需要參與 cost 的計算,這部分的代碼如下:
input_queue_for_train = tf.train.string_input_producer([FLAGS.csv_path])
image_tensor, annotation_tensor = input_image_pipeline(dataset_root_dir_string, input_queue_for_train, FLAGS.batch_size)
dsn_fuse, dsn1, dsn2, dsn3, dsn4, dsn5 = hed_net(image_tensor, FLAGS.batch_size)
cost = class_balanced_sigmoid_cross_entropy(dsn_fuse, annotation_tensor) + \
class_balanced_sigmoid_cross_entropy(dsn1, annotation_tensor) + \
class_balanced_sigmoid_cross_entropy(dsn2, annotation_tensor) + \
class_balanced_sigmoid_cross_entropy(dsn3, annotation_tensor) + \
class_balanced_sigmoid_cross_entropy(dsn4, annotation_tensor) + \
class_balanced_sigmoid_cross_entropy(dsn5, annotation_tensor)
按照這種方式訓練出來的網絡,檢測到的邊緣線是有一點粗的,為了得到更細的邊緣線,通過多次試驗找到了一種優化方案,代碼如下:
input_queue_for_train = tf.train.string_input_producer([FLAGS.csv_path])
image_tensor, annotation_tensor = input_image_pipeline(dataset_root_dir_string, input_queue_for_train, FLAGS.batch_size)
dsn_fuse, _, _, _, _, _ = hed_net(image_tensor, FLAGS.batch_size)
cost = class_balanced_sigmoid_cross_entropy(dsn_fuse, annotation_tensor)
也就是不再讓每個尺度上得到的 image 都參與 cost 的計算,只使用融合后得到的最終 image 來進行計算。
兩種 cost 函數的效果對比如下圖所示,右側是優化過后的效果:
另外還有一點,按照 HED 論文里的要求,計算 cost 的時候,不能使用常見的方差 cost,而應該使用 cost-sensitive loss function,代碼如下:
def class_balanced_sigmoid_cross_entropy(logits, label, name='cross_entropy_loss'):
"""
The class-balanced cross entropy loss,
as in `Holistically-Nested Edge Detection
<http://arxiv.org/abs/1504.06375>`_.
This is more numerically stable than class_balanced_cross_entropy
:param logits: size: the logits.
:param label: size: the ground truth in {0,1}, of the same shape as logits.
:returns: a scalar. class-balanced cross entropy loss
"""
y = tf.cast(label, tf.float32)
count_neg = tf.reduce_sum(1. - y) # the number of 0 in y
count_pos = tf.reduce_sum(y) # the number of 1 in y (less than count_neg)
beta = count_neg / (count_neg + count_pos)
pos_weight = beta / (1 - beta)
cost = tf.nn.weighted_cross_entropy_with_logits(logits, y, pos_weight)
cost = tf.reduce_mean(cost * (1 - beta), name=name)
return cost
轉置卷積層的雙線性初始化
在嘗試 FCN 網絡的時候,就被這個問題卡住過很長一段時間,按照 FCN 的要求,在使用轉置卷積(transposed convolution)/反卷積(deconv)的時候,要把卷積核的值初始化成雙線性放大矩陣(bilinear upsampling kernel),而不是常用的正態分布隨機初始化,同時還要使用很小的學習率,這樣才更容易讓模型收斂。
HED 的論文中,並沒有明確的要求也要采用這種方式初始化轉置卷積層,但是,在訓練過程中發現,采用這種方式進行初始化,模型才更容易收斂。
這部分的代碼如下:
def get_kernel_size(factor):
"""
Find the kernel size given the desired factor of upsampling.
"""
return 2 * factor - factor % 2
def upsample_filt(size):
"""
Make a 2D bilinear kernel suitable for upsampling of the given (h, w) size.
"""
factor = (size + 1) // 2
if size % 2 == 1:
center = factor - 1
else:
center = factor - 0.5
og = np.ogrid[:size, :size]
return (1 - abs(og[0] - center) / factor) * (1 - abs(og[1] - center) / factor)
def bilinear_upsample_weights(factor, number_of_classes):
"""
Create weights matrix for transposed convolution with bilinear filter
initialization.
"""
filter_size = get_kernel_size(factor)
weights = np.zeros((filter_size,
filter_size,
number_of_classes,
number_of_classes), dtype=np.float32)
upsample_kernel = upsample_filt(filter_size)
for i in xrange(number_of_classes):
weights[:, :, i, i] = upsample_kernel
return weights
訓練過程冷啟動
HED 網絡不像 VGG 網絡那樣很容易就進入收斂狀態,也不太容易進入期望的理想狀態,主要是兩方面的原因:
- 前面提到的轉置卷積層的雙線性初始化,就是一個重要因素,因為在 4 個尺度上,都需要反卷積,如果反卷積層不能收斂,那整個 HED 都不會進入期望的理想狀態
- 另外一個原因,是由 HED 的多尺度引起的,既然是多尺度了,那每個尺度上得到的 image 都應該對模型的最終輸出 image 產生貢獻,在訓練的過程中發現,如果輸入 image 的尺寸是 224*224,還是很容易就訓練成功的,但是當把輸入 image 的尺寸調整為 256*256 后,很容易出現一種狀況,就是 5 個尺度上得到的 image,會有 1 ~ 2 個 image 是無效的(全部是黑色)
為了解決這里遇到的問題,采用的辦法就是先使用少量樣本圖片(比如 2000 張)訓練網絡,在很短的訓練時間(比如迭代 1000 次)內,如果 HED 網絡不能表現出收斂的趨勢,或者不能達到 5 個尺度的 image 全部有效的狀態,那就直接放棄這輪的訓練結果,重新開啟下一輪訓練,直到滿意為止,然后才使用完整的訓練樣本集合繼續訓練網絡。
訓練數據集(大量合成數據 + 少量真實數據)
HED 論文里使用的訓練數據集,是針對通用的邊緣檢測目的的,什么形狀的邊緣都有,比如下面這種:
用這份數據訓練出來的模型,在做文檔掃描的時候,檢測出來的邊緣效果並不理想,而且這份訓練數據集的樣本數量也很小,只有一百多張圖片(因為這種圖片的人工標注成本太高了),這也會影響模型的質量。
現在的需求里,要檢測的是具有一定透視和旋轉變換效果的矩形區域,所以可以大膽的猜測,如果准備一批針對性更強的訓練樣本,應該是可以得到更好的邊緣檢測效果的。
借助第一版技術方案收集回來的真實場景圖片,我們開發了一套簡單的標注工具,人工標注了 1200 張圖片(標注這 1200 張圖片的時間成本也很高),但是這 1200 多張圖片仍然有很多問題,比如對於神經網絡來說,1200 個訓練樣本其實還是不夠的,另外,這些圖片覆蓋的場景其實也比較少,有些圖片的相似度比較高,這樣的數據放到神經網絡里訓練,泛化的效果並不好。
所以,還采用技術手段,合成了80000多張訓練樣本圖片。
如上圖所示,一張背景圖和一張前景圖,可以合成出一對訓練樣本數據。在合成圖片的過程中,用到了下面這些技術和技巧:
- 在前景圖上添加旋轉、平移、透視變換
- 對背景圖進行了隨機的裁剪
- 通過試驗對比,生成合適寬度的邊緣線
- OpenCV 不支持透明圖層之間的旋轉和透視變換操作,只能使用最低精度的插值算法,為了改善這一點,后續改成了使用 iOS 模擬器,通過 CALayer 上的操作來合成圖片
- 在不斷改進訓練樣本的過程中,還根據真實樣本圖片的統計情況和各種途徑的反饋信息,刻意模擬了一些更復雜的樣本場景,比如凌亂的背景環境、直線邊緣干擾等等
經過不斷的調整和優化,最終才訓練出一個滿意的模型,可以再次通過下面這張圖表中的第二列看一下神經網絡模型的邊緣檢測效果:
在手機設備上運行 TensorFlow
在手機上使用 TensorFlow 庫
TensorFlow 官方是支持 iOS 和 Android 的,而且有清晰的文檔,照着做就行。但是因為 TensorFlow 是依賴於 protobuf 3 的,所以有可能會遇到一些其他的問題,比如下面這兩種,就是我們在兩個不同的 iOS APP 中遇到的問題和解決辦法,可以作為一個參考:
- A 產品使用的是 protobuf 2,同時由於各種歷史原因,使用並且停留在了很舊的某個版本的 Base 庫上,而 protobuf 3 的內部也使用了 Base 庫,當 A 產品升級到 protobuf 3 后,protobuf 3 的 Base 庫和 A 源碼中的 Base 庫產生了一些奇怪的沖突,最后的解決辦法是手動修改了 A 源碼中的 Base 庫,避免編譯時的沖突
- B 產品也是使用的 protobuf 2,而且 B 產品使用到的多個第三方模塊(沒有源碼,只有二進制文件)也是依賴於 protobuf 2,直接升級 B 產品使用的 protobuf 庫就行不通了,最后采用的方法是修改 TensorFlow 和 TensorFlow 中使用的 protobuf 3 的源代碼,把 protobuf 3 換了一個命名空間,這樣兩個不同版本的 protobuf 庫就可以共存了
Android 上因為本身是可以使用動態庫的,所以即便 app 必須使用 protobuf 2 也沒有關系,不同的模塊使用 dlopen 的方式加載各自需要的特定版本的庫就可以了。
在手機上使用訓練得到的模型文件
模型通常都是在 PC 端訓練的,對於大部分使用者,都是用 Python 編寫的代碼,得到 ckpt 格式的模型文件。在使用模型文件的時候,一種做法就是用代碼重新構建出完整的神經網絡,然后加載這個 ckpt 格式的模型文件,如果是在 PC 上使用模型文件,用這個方法其實也是可以接受的,復制粘貼一下 Python 代碼就可以重新構建整個神經網絡。但是,在手機上只能使用 TensorFlow 提供的 C++ 接口,如果還是用同樣的思路,就需要用 C++ API 重新構建一遍神經網絡,這個工作量就有點大了,而且 C++ API 使用起來比 Python API 復雜的多,所以,在 PC 上訓練完網絡后,還需要把 ckpt 格式的模型文件轉換成 pb 格式的模型文件,這個 pb 格式的模型文件,是用 protobuf 序列化得到的二進制文件,里面包含了神經網絡的具體結構以及每個矩陣的數值,使用這個 pb 文件的時候,不需要再用代碼構建完整的神經網絡結構,只需要反序列化一下就可以了,這樣的話,用 C++ API 編寫的代碼就會簡單很多,其實這也是 TensorFlow 推薦的使用方法,在 PC 上使用模型的時候,也應該使用這種 pb 文件(訓練過程中使用 ckpt 文件)。
HED 網絡在手機上遇到的奇怪 crash
在手機上加載 pb 模型文件並且運行的時候,遇到過一個詭異的錯誤,內容如下:
Invalid argument: No OpKernel was registered to support Op 'Mul' with these attrs. Registered devices: [CPU], Registered kernels:
device='CPU'; T in [DT_FLOAT]
[[Node: hed/mul_1 = Mul[T=DT_INT32](hed/strided_slice_2, hed/mul_1/y)]]
之所以詭異,是因為從字面上看,這個錯誤的含義是缺少乘法操作(Mul),但是我用其他的神經網絡模型做過對比,乘法操作模塊是可以正常工作的。
Google 搜索后發現很多人遇到過類似的情況,但是錯誤信息又並不相同,后來在 TensorFlow 的 github issues 里終於找到了線索,綜合起來解釋,是因為 TensorFlow 是基於操作(Operation)來模塊化設計和編碼的,每一個數學計算模塊就是一個 Operation,由於各種原因,比如內存占用大小、GPU 獨占操作等等,mobile 版的 TensorFlow,並沒有包含所有的 Operation,mobile 版的 TensorFlow 支持的 Operation 只是 PC 完整版 TensorFlow 的一個子集,我遇到的這個錯誤,就是因為使用到的某個 Operation 並不支持 mobile 版。
按照這個線索,在 Python 代碼中逐個排查,后來定位到了出問題的代碼,修改前后的代碼如下:
def deconv(inputs, upsample_factor):
input_shape = tf.shape(inputs)
# Calculate the ouput size of the upsampled tensor
upsampled_shape = tf.pack([input_shape[0],
input_shape[1] * upsample_factor,
input_shape[2] * upsample_factor,
1])
upsample_filter_np = bilinear_upsample_weights(upsample_factor, 1)
upsample_filter_tensor = tf.constant(upsample_filter_np)
# Perform the upsampling
upsampled_inputs = tf.nn.conv2d_transpose(inputs, upsample_filter_tensor,
output_shape=upsampled_shape,
strides=[1, upsample_factor, upsample_factor, 1])
return upsampled_inputs
def deconv_mobile_version(inputs, upsample_factor, upsampled_shape):
upsample_filter_np = bilinear_upsample_weights(upsample_factor, 1)
upsample_filter_tensor = tf.constant(upsample_filter_np)
# Perform the upsampling
upsampled_inputs = tf.nn.conv2d_transpose(inputs, upsample_filter_tensor,
output_shape=upsampled_shape,
strides=[1, upsample_factor, upsample_factor, 1])
return upsampled_inputs
問題就是由 deconv 函數中的 tf.shape 和 tf.pack 這兩個操作引起的,在 PC 版代碼中,為了簡潔,是基於這兩個操作,自動計算出 upsampled_shape,修改過后,則是要求調用者用 hard coding 的方式設置對應的 upsampled_shape。
裁剪 TensorFlow
TensorFlow 是一個很龐大的框架,對於手機來說,它占用的體積是比較大的,所以需要盡量的縮減 TensorFlow 庫占用的體積。
其實在解決前面遇到的那個 crash 問題的時候,已經指明了一種裁剪的思路,既然 mobile 版的 TensorFlow 本來就是 PC 版的一個子集,那就意味着可以根據具體的需求,讓這個子集變得更小,這也就達到了裁剪的目的。具體來說,就是修改 TensorFlow 源碼中的 tensorflow/tensorflow/contrib/makefile/tf_op_files.txt 文件,只保留使用到了的模塊。針對 HED 網絡,原有的 200 多個模塊裁剪到只剩 46 個,裁剪過后的 tf_op_files.txt 文件如下:
tensorflow/core/kernels/xent_op.cc
tensorflow/core/kernels/where_op.cc
tensorflow/core/kernels/unpack_op.cc
tensorflow/core/kernels/transpose_op.cc
tensorflow/core/kernels/transpose_functor_cpu.cc
tensorflow/core/kernels/tensor_array_ops.cc
tensorflow/core/kernels/tensor_array.cc
tensorflow/core/kernels/split_op.cc
tensorflow/core/kernels/split_v_op.cc
tensorflow/core/kernels/split_lib_cpu.cc
tensorflow/core/kernels/shape_ops.cc
tensorflow/core/kernels/session_ops.cc
tensorflow/core/kernels/sendrecv_ops.cc
tensorflow/core/kernels/reverse_op.cc
tensorflow/core/kernels/reshape_op.cc
tensorflow/core/kernels/relu_op.cc
tensorflow/core/kernels/pooling_ops_common.cc
tensorflow/core/kernels/pack_op.cc
tensorflow/core/kernels/ops_util.cc
tensorflow/core/kernels/no_op.cc
tensorflow/core/kernels/maxpooling_op.cc
tensorflow/core/kernels/matmul_op.cc
tensorflow/core/kernels/immutable_constant_op.cc
tensorflow/core/kernels/identity_op.cc
tensorflow/core/kernels/gather_op.cc
tensorflow/core/kernels/gather_functor.cc
tensorflow/core/kernels/fill_functor.cc
tensorflow/core/kernels/dense_update_ops.cc
tensorflow/core/kernels/deep_conv2d.cc
tensorflow/core/kernels/xsmm_conv2d.cc
tensorflow/core/kernels/conv_ops_using_gemm.cc
tensorflow/core/kernels/conv_ops_fused.cc
tensorflow/core/kernels/conv_ops.cc
tensorflow/core/kernels/conv_grad_filter_ops.cc
tensorflow/core/kernels/conv_grad_input_ops.cc
tensorflow/core/kernels/conv_grad_ops.cc
tensorflow/core/kernels/constant_op.cc
tensorflow/core/kernels/concat_op.cc
tensorflow/core/kernels/concat_lib_cpu.cc
tensorflow/core/kernels/bias_op.cc
tensorflow/core/ops/sendrecv_ops.cc
tensorflow/core/ops/no_op.cc
tensorflow/core/ops/nn_ops.cc
tensorflow/core/ops/nn_grad.cc
tensorflow/core/ops/array_ops.cc
tensorflow/core/ops/array_grad.cc
需要強調的一點是,這種操作思路,是針對不同的神經網絡結構有不同的裁剪方式,原則就是用到什么模塊就保留什么模塊。當然,因為有些模塊之間還存在隱含的依賴關系,所以裁剪的時候也是要反復嘗試多次才能成功的。
除此之外,還有下面這些通用手段也可以實現裁剪的目的:
- 編譯器級別的 strip 操作,在鏈接的時候會自動的把沒有調用到的函數去除掉(集成開發環境里通常已經自動將這些參數設置成了最佳組合)
- 借助一些高級技巧和工具,對二進制文件進行瘦身
借助所有這些裁剪手段,最終我們的 ipa 安裝包的大小只增加了 3M。如果不做手動裁剪這一步,那 ipa 的增量,則是 30M 左右。
裁剪 HED 網絡
按照 HED 論文給出的參考信息,得到的模型文件的大小是 56M,對於手機來說也是比較大的,而且模型越大也意味着計算量越大,所以需要考慮能否把 HED 網絡也裁剪一下。
HED 網絡是用 VGG16 作為基礎網絡結構,而 VGG 又是一個得到廣泛驗證的基礎網絡結構,因此修改 HED 的整體結構肯定不是一個明智的選擇,至少不是首選的方案。
考慮到現在的需求,只是檢測矩形區域的邊緣,而並不是檢測通用場景下的廣義的邊緣,可以認為前者的復雜度比后者更低,所以一種可行的思路,就是保留 HED 的整體結構,修改 VGG 每一組卷積層里面的卷積核的數量,讓 HED 網絡變的更『瘦』。
按照這種思路,經過多次調整和嘗試,最終得到了一組合適的卷積核的數量參數,對應的模型文件只有 4.2M,在 iPhone 7P 上,處理每幀圖片的時間消耗是 0.1 秒左右,滿足實時性的要求。
神經網絡的裁剪,目前在學術界也是一個很熱門的領域,有好幾種不同的理論來實現不同目的的裁剪,但是,也並不是說每一種網絡結構都有裁剪的空間,通常來說,應該結合實際情況,使用合適的技術手段,選擇一個合適大小的模型文件。
TensorFlow API 的選擇
TensorFlow 的 API 是很靈活的,也比較底層,在學習過程中發現,每個人寫出來的代碼,風格差異很大,而且很多工程師又采用了各種各樣的技巧來簡化代碼,但是這其實反而在無形中又增加了代碼的閱讀難度,也不利於代碼的復用。
第三方社區和 TensorFlow 官方,都意識到了這個問題,所以更好的做法是,使用封裝度更高但又保持靈活性的 API 來進行開發。本文中的代碼,就是使用 TensorFlow-Slim 編寫的。
OpenCV 算法
雖然用神經網絡技術,已經得到了一個比 canny 算法更好的邊緣檢測效果,但是,神經網絡也並不是萬能的,干擾是仍然存在的,所以,第二個步驟中的數學模型算法,仍然是需要的,只不過因為第一個步驟中的邊緣檢測有了大幅度改善,所以第二個步驟中的算法,得到了適當的簡化,而且算法整體的適應性也更強了。
這部分的算法如下圖所示:
按照編號順序,幾個關鍵步驟做了下面這些事情:
- 用 HED 網絡檢測邊緣,可以看到,這里得到的邊緣線還是存在一些干擾的
- 在前一步得到的圖像上,使用 HoughLinesP 函數檢測線段(藍色線段)
- 把前一步得到的線段延長成直線(綠色直線)
- 在第二步中檢測到的線段,有一些是很接近的,或者有些短線段是可以連接成一條更長的線段的,所以可以采用一些策略把它們合並到一起,這個時候,就要借助第三步中得到的直線。定義一種策略判斷兩條直線是否相等,當遇到相等的兩條直線時,把這兩條直線各自對應的線段再合並或連接成一條線段。這一步完成后,后面的步驟就只需要藍色的線段而不需要綠色的直線了
- 根據第四步得到的線段,計算它們之間的交叉點,臨近的交叉點也可以合並,同時,把每一個交叉點和產生這個交叉點的線段也要關聯在一起(每一個藍色的點,都有一組紅色的線段和它關聯)
- 對於第五步得到的所有交叉點,每次取出其中的 4 個,判斷這 4 個點組成的四邊形是否是一個合理的矩形(有透視變換效果的矩形),除了常規的判斷策略,比如角度、邊長的比值之外,還有一個判斷條件就是每條邊是否可以和第五步中得到的對應的點的關聯線段重合,如果不能重合,則這個四邊形就不太可能是我們期望檢測出來的矩形
- 經過第六步的過濾后,如果得到了多個四邊形,可以再使用一個簡單的過濾策略,比如排序找出周長或面積最大的矩形
對於上面這個例子,第一版技術方案中檢測出來的邊緣線如下圖所示:
有興趣的讀者也可以考慮一下,在這種邊緣圖中,如何設計算法才能找出我們期望的那個矩形。
總結
算法角度
- 神經網絡的參數/超參數的調優,通常只能基於經驗來設置,有 magic trick 的成分
- 神經網絡/機器學習是一門試驗科學
- 對於監督學習,數據的標注成本很高,這一步很容易出現瓶頸
- 論文、參考代碼和自己的代碼,這三者之間不完全一致也是正常現象
- 對於某些需求,可以在模型的准確度、大小和運行速度之間找一個平衡點
工程角度
- end-to-end 網絡無效的時候,可以用 pipeline 的思路考慮問題、拆分業務,針對性的使用神經網絡技術
- 至少要熟練掌握一種神經網絡的開發框架,而且要追求代碼的工程質量
- 要掌握神經網絡技術中的一些基本套路,舉一反三
- 要在學術界和工業界中間找平衡點,盡可能多的學習一些不同問題領域的神經網絡模型,作為技術儲備
參考文獻
Hacker's guide to Neural Networks
神經網絡淺講:從神經元到深度學習
分類與回歸區別是什么?
神經網絡架構演進史:全面回顧從LeNet5到ENet十余種架構
數據的游戲:冰與火
為什么“高大上”的算法工程師變成了數據民工?
Facebook人工智能負責人Yann LeCun談深度學習的局限性
The best explanation of Convolutional Neural Networks on the Internet!
從入門到精通:卷積神經網絡初學者指南
Transposed Convolution, Fractionally Strided Convolution or Deconvolution
A technical report on convolution arithmetic in the context of deep learning
Visualizing what ConvNets learn
Visualizing Features from a Convolutional Neural Network
Neural networks: which cost function to use?
difference between tensorflow tf.nn.softmax and tf.nn.softmax_cross_entropy_with_logits
Why You Should Use Cross-Entropy Error Instead Of Classification Error Or Mean Squared Error For Neural Network Classifier Training
Tensorflow 3 Ways
TensorFlow-Slim
TensorFlow-Slim image classification library
Holistically-Nested Edge Detection
深度卷積神經網絡在目標檢測中的進展
全卷積網絡:從圖像級理解到像素級理解
圖像語義分割之FCN和CRF
Image Classification and Segmentation with Tensorflow and TF-Slim
Upsampling and Image Segmentation with Tensorflow and TF-Slim
Image Segmentation with Tensorflow using CNNs and Conditional Random Fields
How to Build a Kick-Ass Mobile Document Scanner in Just 5 Minutes
MAKE DOCUMENT SCANNER USING PYTHON AND OPENCV
Fast and Accurate Document Detection for Scanning
更多精彩內容歡迎關注騰訊 Bugly的微信公眾賬號:
騰訊 Bugly是一款專為移動開發者打造的質量監控工具,幫助開發者快速,便捷的定位線上應用崩潰的情況以及解決方案。智能合並功能幫助開發同學把每天上報的數千條 Crash 根據根因合並分類,每日日報會列出影響用戶數最多的崩潰,精准定位功能幫助開發同學定位到出問題的代碼行,實時上報可以在發布后快速的了解應用的質量情況,適配最新的 iOS, Android 官方操作系統,鵝廠的工程師都在使用,快來加入我們吧!