【Pytorch】基於卷積神經網路實現的面部表情識別

小何學長 發表於 2021-12-17
神經網路 PyTorch

作者:何翔

學院:計算機學院

學號:04191315

班級:軟體1903

轉載請標註本文連結: https://www.cnblogs.com/He-Xiang-best/p/15690252.html

一、緒論

1.1 研究背景

面部表情識別 (Facial Expression Recognition )

在日常工作和生活中,人們情感的表達方式主要有:語言、聲音、肢體行為(如手勢)、以及面部表情等。在這些行為方式中,面部表情所攜帶的表達人類內心情感活動的資訊最為豐富,據研究表明,人類的面部表情所攜帶的內心活動的資訊在所有的上述的形式中比例最高,大約佔比55%。

人類的面部表情變化可以傳達出其內心的情緒變化,表情是人類內心世界的真實寫照。上世紀70年代,美國著名心理學家保羅•艾克曼經過大量實驗之後,將人類的基本表情定義為悲傷、害怕、厭惡、快樂、氣憤和驚訝六種。同時,他們根據不同的面部表情類別建立了相應的表情影像資料庫。隨著研究的深入,中性表情也被研究學者加入基本面部表情中,組成了現今的人臉表情識別研究中的七種基礎面部表情。

image

由於不同的面部表情,可以反映出在不同情景下人們的情緒變化以及心理變化,因此面部表情的識別對於研究人類行為和心理活動,具有十分重要的研究意義和實際應用價值。現如今,面部表情識別主要使用計算機對人類面部表情進行分析識別,從而分析認得情緒變化,這在人機互動、社交網路分析、遠端醫療以及刑偵監測等方面都具有重要意義。

1.2 研究意義

在計算機視覺中,因為表情識別作為人機互動的一種橋樑,可以更好的幫助機器瞭解、識別人類的內心活動,從而更好的服務人類,因此對人臉表情識別進行深入研究具有十分重要的意義。而在研究人臉表情的過程中,如何有效地提取人臉表情特徵,是人臉表情識別中最為關鍵的步驟。人臉表情識別從出現到現在已歷經數十載,在過去的面部表情識別方法裡還是主要依靠於人工設計的特徵(比如邊和紋理描述量)和機器學習技術(如主成分分析、線性判別分析或支援向量機)的結合。但是在無約束的環境中,人工設計對不同情況的特徵提取是很困難的,同時容易受外來因素的干擾(光照、角度、複雜背景等),進而導致識別率下降。

隨著科學技術的發展,傳統的一些基於手工提取人臉表情影像特徵的方法,因為需要研究人員具有比較豐富的經驗,具有比較強的侷限性,從而給人臉表情識別的研究造成了比較大的困難。隨著深度學習的興起,作為深度學習的經典代表的卷積神經網路,由於其具有自動提取人臉表情影像特徵的優勢,使得基於深度學習的人臉表情特徵提取方法逐漸興起,並逐步替代一些傳統的人臉表情特徵提取的方法。深度學習方法的主要優點在於它們可通過使用非常大的資料集進行訓練學習從而獲得表徵這些資料的最佳功能。在深度學習中,使用卷積神經網路作為人臉表情特徵提取的工具,可以更加完整的提取人臉表情特徵,解決了一些傳統手工方法存在的提取人臉表情特徵不充足的問題。

將人臉表情識別演算法按照特徵提取方式進行分類,其主要分為兩種:一是基於傳統的計算機視覺的提取演算法。該類方法主要依賴於研究人員手工設計來提取人臉表情特徵;二是基於深度學習的演算法。該方法使用卷積神經網路,自動地提取人臉表情特徵。卷積神經網路對原始影像進行簡單的預處理之後,可以直接輸入到網路中,使用端到端的學習方法,即不經過傳統的機器學習複雜的中間建模過程,如在識別中對資料進行標註、翻轉處理等,直接一次性將資料標註好,同時學習特徵與進行分類,這是深度學習方法與傳統方法的重要區別。相比人工的選取與設計影像特徵,卷積神經網路通過自動學習的方式,獲得的樣本資料的深度特徵資訊擁有更好的抗噪聲能力、投影不變性、推廣與泛化能力、抽象語義表示能力。

二、理論分析與研究

2.1 面部表情識別框架

面部表情識別通常可以劃分為四個程式。包括影像獲取,面部檢測,影像預處理和表情分類。其中,面部檢測,臉部特徵提取和麵部表情分類是面部表情識別的三個關鍵環節面部表情識別的基本框架如下圖所示。
image

首先是獲取影像並執行面部檢測,然後提取僅具有面部的影像部分。所提取的面部表情在比例和灰度上不均勻,因此有必要對面部特徵區域進行分割和歸一化,其中執行歸一化主要是對面部光照和位置進行統一處理,將影像統一重塑為標準大小,如 48×48 畫素的圖片,即影像預處理。然後對臉部影像提取面部表情特徵值,並進行分類。採用卷積神經網路(CNN)來完成特徵提取和分類的任務,因為 CNN 是模仿人腦工作並建立卷積神經網路結構模型的著名模型,所以選擇卷積神經網路作為構建模型體系結構的基礎,最後不斷訓練,優化,最後達到較準確識別出面部表情的結果。

影像預處理

採用多普勒擴充套件法的幾何歸一化分為兩個主要步驟:面部校正和麵部修剪。

主要目的是將影像轉化為統一大小。

具體步驟如下:

(1)找到特徵點並對其進行標記,首先選取兩眼和鼻子作為三個特徵點並採用一個函式對其進行標記,這裡選擇的函式是[x,y]=ginput(3)。這裡重要的一點是獲得特徵點的座標值,可以用滑鼠進行調整。

(2)兩眼的座標值可以看作參考點,將兩眼之間的距離設定為 d,找到兩眼間的中點並標記為 O,然後根據參考點對影像進行旋轉,這步操作是為了保證將人臉影像調到一致。

(3)接下來以選定的 O 為基準,分別向左右兩個方向各剪下距離為 d 的區域,在垂直方向剪下 0.5d 和 1.5d 的區域,這樣就可以根據面部特徵點和幾何模型對特徵區域進行確定,如下圖所示:

image

(4)為了更好的對錶情進行提取,可將表情的子區域影像裁剪成統一的 48×48 尺寸。

2.2 基於 CNN 的人臉面部表情識別演算法

卷積神經網路(CNN)是一種前饋神經網路,它包括卷積計算並具有較深的結構,因此是深度學習的代表性演算法之一。隨著科技的不斷進步,人們在研究人腦組織時受啟發創立了神經網路。神經網路由很多相互聯絡的神經元組成,並且可以在不同的神經元之間通過調整傳遞彼此之間聯絡的權重係數 x 來增強或抑制訊號。標準卷積神經網路通常由輸入層、卷積層、池化層、全連線層和輸出層組成,如下圖所示:

image

上圖中第一層為輸入層,大小為 28×28,然後通過 20×24×24 的卷積層,得到的結果再輸入池化層中,最後再通過圖中第四層既全連線層,直到最後輸出。

下圖為CNN常見的網路模型。其中包括 4 個卷積層,3 個池化層,池化層的大小為 3×3,最終再通過兩個全連線層到達輸出層。網路模型中的輸入層一般是一個矩陣,卷積層,池化層和全連線層可以當作隱藏層,這些層通常具有不同的計算方法,需要學習權重以找到最佳值。

image

從上述中可知,標準卷積神經網路除了輸入和輸出外,還主要具有三種型別:池化層,全連線層和卷積層。這三個層次是卷積神經網路的核心部分。

2.2.1 卷積層

卷積層是卷積神經網路的第一層,由幾個卷積單元組成。每個卷積單元的引數可以通過反向傳播演算法進行優化,其目的是提取輸入的各種特徵,但是卷積層的第一層只能提取低階特徵,例如邊、線和角。更多層的可以提取更高階的特徵,利用卷積層對人臉面部影像進行特徵提取。一般卷積層結構如下圖所示,卷積層可以包含多個卷積面,並且每個卷積面都與一個卷積核相關聯。

image

由上圖可知,每次執行卷積層計算時,都會生成與之相關的多個權重引數,這些權重引數的數量與卷積層的數量相關,即與卷積層所用的函式有直接的關係。

2.2.2 池化層

在卷積神經網路中第二個隱藏層便是池化層,在卷積神經網路中,池化層通常會在卷積層之間,由此對於縮小引數矩陣的尺寸有很大幫助,也可以大幅減少全連線層中的引數數量。此外池化層在加快計算速度和防止過擬合方面也有很大的作用。在識別影像的過程中,有時會遇到較大的影像,此時希望減少訓練引數的數量,這時需要引入池化層。池化的唯一目的是減小影像空間的大小。常用的有 mean-pooling 和max-pooling。mean-pooling 即對一小塊區域取平均值,假設 pooling 窗的大小是 2×2,那麼就是在前面卷積層的輸出的不重疊地進行 2×2 的取平均值降取樣,就得到 mean-pooling 的值。不重疊的 4 個 2×2 區域分別 mean-pooling 如下圖所示。

image

max-pooling 即對一小塊區域取最大值,假設 pooling 的窗大小是 2×2,就是在前面卷積層的輸出的不重疊地進行 2×2 的取最大值降取樣,就得到 max-pooling 的值。不重疊的 4 個 2×2 區域分別max-pooling 如下圖所示:

image

2.2.3 全連線層

卷積神經網路中的最後一個隱藏層是全連線層。該層的角色與之前的隱藏層完全不同。卷積層和池化層的功能均用於面部影像的特徵提取,而全連線層的主要功能就是對影像的特徵矩陣進行分類。根據不同的狀況,它可以是一層或多層。

通過該層的圖片可以高度濃縮為一個數。由此全連線層的輸出就是高度提純的特徵了,便於移交給最後的分類器或者回歸。

2.2.4 網路的訓練

神經網路通過自學習的方式可以獲得高度抽象的,手工特徵無法達到的特徵,在計算機視覺領域已經取得了革命性的突破。被廣泛的應用於生活中的各方面。而要想讓神經網路智慧化,必須對它進行訓練,在訓練過程中一個重要的演算法就是反向傳播演算法。反向傳播演算法主要是不斷調整網路的權重和閾值,以得到最小化網路的平方誤差之和,然後可以輸出想要的結果。

2.2.5 CNN模型的演算法評價

卷積神經網路由於強大的特徵學習能力被應用於面部表情識別中,從而極大地提高了面部表情特徵提取的效率。與此同時,卷積神經網路相比於傳統的面部表情識別方法在資料的預處理和資料格式上得到了很大程度的簡化。例如,卷積神經網路不需要輸入歸一化和格式化的資料。基於以上優點,卷積神經網路在人類面部表情識別這一領域中的表現要遠遠優於其他傳統演算法。

2.3 基於 VGG 的人臉面部表情識別演算法

隨著深度學習演算法的不斷髮展,眾多卷積神經網路演算法已經被應用到機器視覺領域中。儘管卷積神經網路極大地提高了面部表情特徵提取的效率,但是,基於卷積神經網路的演算法仍存在兩個較為典型的問題:

(1)忽略影像的二維特性。

(2)常規神經網路提取的表情特徵魯棒性較差。

因此,我們需要尋找或設計一種對人類面部表情的識別更加優化並準確的深度卷積神經網路模型

2.3.1 VGG模型原理

VGG模型的提出

VGGNet是由牛津大學視覺幾何小組(Visual Geometry Group, VGG)提出的一種深層卷積網路結構,網路名稱VGGNet取自該小組名縮寫。VGGNet是首批把影像分類的錯誤率降低到10%以內模型,同時該網路所採用的3\times33×3卷積核的思想是後來許多模型的基礎,該模型發表在2015年國際學習表徵會議(International Conference On Learning Representations, ICLR)後至今被引用的次數已經超過1萬4千餘次。

image

在原論文中的VGGNet包含了6個版本的演進,分別對應VGG11、VGG11-LRN、VGG13、VGG16-1、VGG16-3和VGG19,不同的字尾數值表示不同的網路層數(VGG11-LRN表示在第一層中採用了LRN的VGG11,VGG16-1表示後三組卷積塊中最後一層卷積採用卷積核尺寸為 1\times11×1 ,相應的VGG16-3表示卷積核尺寸為 3\times33×3 )。下面主要以的VGG16-3為例。

image

上圖中的VGG16體現了VGGNet的核心思路,使用 3\times33×3 的卷積組合代替大尺寸的卷積(2個 3\times33×3 卷積即可與 5\times55×5 卷積擁有相同的感受視野)。

感受野(Receptive Field),指的是神經網路中神經元“看到的”輸入區域,在卷積神經網路中,feature map上某個元素的計算受輸入影像上某個區域的影響,這個區域即該元素的感受野。那麼如果在我感受野相同的條件下,我讓中間層數更多,那麼能提取到的特徵就越豐富,效果就會更好。

VGG塊的組成規律是:連續使用數個相同的填充為1、視窗形狀為 3\times33×3 的卷積層後接上一個步幅為2、視窗形狀為 2\times22×2 的最大池化層。卷積層保持輸入的高和寬不變,而池化層則對其減半。

2.3.2 VGG模型的優點

(1)小卷積核: 將卷積核全部替換為3x3(極少用了1x1),作用就是減少引數,減小計算量。此外採用了更小的卷積核我們就可以使網路的層數加深,就可以加入更多的啟用函式,更豐富的特徵,更強的辨別能力。卷積後都伴有啟用函式,更多的卷積核的使用可使決策函式更加具有辨別能力。其實最重要的還是多個小卷積堆疊在分類精度上比單個大卷積要好。

(2)小池化核: 相比AlexNet的3x3的池化核,VGG全部為2x2的池化核。

(3)層數更深: 從作者給出的6個試驗中我們也可以看到,最後兩個實驗的的層數最深,效果也是最好。

(4)卷積核堆疊的感受野: 作者在VGGnet的試驗中只使用了兩中卷積核大小:1*1,3*3。並且作者也提出了一種想法:兩個3*3的卷積堆疊在一起獲得的感受野相當於一個5*5卷積;3個3x3卷積的堆疊獲取到的感受野相當於一個7x7的卷積。


image

  • input=8,3層conv3x3後,output=2,等同於1層conv7x7的結果;
  • input=8,2層conv3x3後,output=2,等同於2層conv5x5的結果。

由上圖可知,輸入的8個神經元可以想象為feature map的寬和高,conv3 、conv5 、conv7 、對應stride=1,pad=0 。從結果我們可以得出上面推斷的結論。此外,倒著看網路,也就是 backprop 的過程,每個神經元相對於前一層甚至輸入層的感受野大小也就意味著引數更新會影響到的神經元數目。在分割問題中卷積核的大小對結果有一定的影響,在上圖三層的 conv3x3 中,最後一個神經元的計算是基於第一層輸入的7個神經元,換句話說,反向傳播時,該層會影響到第一層 conv3x3 的前7個引數。從輸出層往回forward同樣的層數下,大卷積影響(做引數更新時)到的前面的輸入神經元越多。

(5)全連線轉卷積:VGG另一個特點就是使用了全連線轉全卷積,它把網路中原本的三個全連線層依次變為1個conv7x7,2個conv1x1,也就是三個卷積層。改變之後,整個網路由於沒有了全連線層,網路中間的 feature map 不會固定,所以網路對任意大小的輸入都可以處理。

2.3.3 VGG模型的演算法評價

綜上所述,VGG採用連續的小卷積核代替較大卷積核,以獲取更大的網路深度。 例如,使用 2 個 3∗3 卷積核代替 5∗5 卷積核。這種方法使得在確保相同感知野的條件下,VGG 網路具有比一般的 CNN 更大的網路深度,提升了神經網路特徵提取及分類的效果。

2.4 基於 ResNet 的人臉面部表情識別演算法

2.4.1 ResNet模型原理

ResNet模型的提出

ResNet(Residual Neural Network)由微軟研究院的Kaiming He等四名華人提出,通過使用ResNet Unit成功訓練出了152層的神經網路,並在ILSVRC2015比賽中取得冠軍,在top5上的錯誤率為3.57%,同時引數量比VGGNet低,效果非常突出。ResNet的結構可以極快的加速神經網路的訓練,模型的準確率也有比較大的提升。同時ResNet的推廣性非常好,甚至可以直接用到InceptionNet網路中。

下圖是ResNet34層模型的結構簡圖:

image

在ResNet網路中有如下幾個亮點:

  • 提出residual結構(殘差結構),並搭建超深的網路結構(突破1000層)
  • 使用Batch Normalization加速訓練(丟棄dropout)

在ResNet網路提出之前,傳統的卷積神經網路都是通過將一系列卷積層與下采樣層進行堆疊得到的。但是當堆疊到一定網路深度時,就會出現兩個問題。

  • 梯度消失或梯度爆炸。
  • 退化問題(degradation problem)。

在ResNet論文中說通過資料的預處理以及在網路中使用BN(Batch Normalization)層能夠解決梯度消失或者梯度爆炸問題。但是對於退化問題(隨著網路層數的加深,效果還會變差,如下圖所示)並沒有很好的解決辦法。

image

所以ResNet論文提出了residual結構(殘差結構)來減輕退化問題。下圖是使用residual結構的卷積網路,可以看到隨著網路的不斷加深,效果並沒有變差,反而變的更好了。

image

2.4.2 殘差結構(residual)

殘差指的是什麼?

其中ResNet提出了兩種mapping:一種是identity mapping,指的就是下圖中”彎彎的曲線”,另一種residual mapping,指的就是除了”彎彎的曲線“那部分,所以最後的輸出是 y=F(x)+x

identity mapping

顧名思義,就是指本身,也就是公式中的x,而residual mapping指的是“差”,也就是y−x,所以殘差指的就是F(x)部分。

下圖是論文中給出的兩種殘差結構。左邊的殘差結構是針對層數較少網路,例如ResNet18層和ResNet34層網路。右邊是針對網路層數較多的網路,例如ResNet101,ResNet152等。為什麼深層網路要使用右側的殘差結構呢。因為,右側的殘差結構能夠減少網路引數與運算量。同樣輸入一個channel為256的特徵矩陣,如果使用左側的殘差結構需要大約1170648個引數,但如果使用右側的殘差結構只需要69632個引數。明顯搭建深層網路時,使用右側的殘差結構更合適。

image

下面先對左側的殘差結構(針對ResNet18/34)進行一個分析。如下圖所示,該殘差結構的主分支是由兩層3x3的卷積層組成,而殘差結構右側的連線線是shortcut分支也稱捷徑分支(注意為了讓主分支上的輸出矩陣能夠與我們捷徑分支上的輸出矩陣進行相加,必須保證這兩個輸出特徵矩陣有相同的shape)。如果剛剛仔細觀察了ResNet34網路結構圖,應該能夠發現圖中會有一些虛線的殘差結構。在原論文中作者只是簡單說了這些虛線殘差結構有降維的作用,並在捷徑分支上通過1x1的卷積核進行降維處理。而下圖右側給出了詳細的虛線殘差結構,注意下每個卷積層的步距stride,以及捷徑分支上的卷積核的個數(與主分支上的卷積核個數相同)。

image

接著再來分析下針對ResNet50/101/152的殘差結構,如下圖所示。在該殘差結構當中,主分支使用了三個卷積層,第一個是1x1的卷積層用來壓縮channel維度,第二個是3x3的卷積層,第三個是1x1的卷積層用來還原channel維度(注意主分支上第一層卷積層和第二次卷積層所使用的卷積核個數是相同的,第三次是第一層的4倍)。該殘差結構所對應的虛線殘差結構如下圖右側所示,同樣在捷徑分支上有一層1x1的卷積層,它的卷積核個數與主分支上的第三層卷積層卷積核個數相同,注意每個卷積層的步距。

image

下面這幅圖是原論文給出的不同深度的ResNet網路結構配置,注意表中的殘差結構給出了主分支上卷積核的大小與卷積核個數,表中的xN表示將該殘差結構重複N次。那到底哪些殘差結構是虛線殘差結構呢。

image

對於我們ResNet18/34/50/101/152,表中conv3_x, conv4_x, conv5_x所對應的一系列殘差結構的第一層殘差結構都是虛線殘差結構。因為這一系列殘差結構的第一層都有調整輸入特徵矩陣shape的使命(將特徵矩陣的高和寬縮減為原來的一半,將深度channel調整成下一層殘差結構所需要的channel)。下面給出了簡單標註了一些資訊的ResNet34網路結構圖。

image

對於ResNet50/101/152,其實在conv2_x所對應的一系列殘差結構的第一層也是虛線殘差結構。因為它需要調整輸入特徵矩陣的channel,根據表格可知通過3x3的max pool之後輸出的特徵矩陣shape應該是[56, 56, 64],但我們conv2_x所對應的一系列殘差結構中的實線殘差結構它們期望的輸入特徵矩陣shape是[56, 56, 256](因為這樣才能保證輸入輸出特徵矩陣shape相同,才能將捷徑分支的輸出與主分支的輸出進行相加)。所以第一層殘差結構需要將shape從[56, 56, 64] --> [56, 56, 256]。注意,這裡只調整channel維度,高和寬不變(而conv3_x, conv4_x, conv5_x所對應的一系列殘差結構的第一層虛線殘差結構不僅要調整channel還要將高和寬縮減為原來的一半)。

2.4.3 ResNet模型的演算法評價

ResNet已經被廣泛運用於各種特徵提取應用中,它的出現解決了網路層數到一定的深度後分類效能和準確率不能提高的問題,深度殘差網路與傳統卷積神經網路相比,在網路中引入殘差模組,該模組的引入有效地緩解了網路模型訓練時反向傳播的梯度消失問題,進而解決了深層網路難以訓練和效能退化的問題。

三、人臉面部表情識別專案設計

3.1 專案簡介

本專案是基於卷積神經網路模型開展表情識別的研究,為了儘可能的提高最終表情識別的準確性,需要大量的樣本圖片訓練,優化,所以採用了 FER2013 資料集用來訓練、測試,此資料集由 35886 張人臉表情圖片組成,其中,測試圖 28708 張,公共驗證圖和私有驗證圖各 3589 張,所有圖片中共有7種表情。在預處理時把影像歸一化為 48×48 畫素,訓練的網路結構是基於 CNN 網路結構的優化改進後的一個開源的網路結構,下文中會具體介紹到,通過不斷地改進優化,縮小損失率,最終能達到較準確的識別出人的面部表情的結果。

3.2 資料集準備

本專案採用了FER2013資料庫,其資料集的下載地址如下:

https://www.kaggle.com/c/challenges-in-representation-learning-facial-expression-recognition-challenge/data

FER2013資料集由28709張訓練圖,3589張公開測試圖和3589張私有測試圖組成。每一張圖都是畫素為48*48的灰度圖。FER2013資料庫中一共有7中表情:憤怒,厭惡,恐懼,開心,難過,驚訝和中性。該資料庫是2013年Kaggle比賽的資料,由於這個資料庫大多是從網路爬蟲下載的,存在一定的誤差性。這個資料庫的人為準確率是65% 士 5%。

image

image

image

image


3.3 資料集介紹

給定的資料集train.csv,我們要使用卷積神經網路CNN,根據每個樣本的面部圖片判斷出其表情。在本專案中,表情共分7類,分別為:(0)生氣,(1)厭惡,(2)恐懼,(3)高興,(4)難過,(5)驚訝和(6)中立(即面無表情,無法歸為前六類)。因此,專案實質上是一個7分類問題。

image

train.csv檔案說明:

(1)CSV檔案,大小為28710行X2305列;

(2)在28710行中,其中第一行為描述資訊,即“emotion”和“pixels”兩個單詞,其餘每行內含有一個樣本資訊,即共有28709個樣本;

(3)在2305列中,其中第一列為該樣本對應的emotion,取值範圍為0到6。其餘2304列為包含著每個樣本大小為48X48人臉圖片的畫素值(2304=48X48),每個畫素值取值範圍在0到255之間;

image

3.4 資料集分離

在原檔案中,emotion和pixels人臉畫素資料是集中在一起的。為了方便操作,決定利用pandas庫進行資料分離,即將所有emotion讀出後,寫入新建立的檔案emotion.csv;將所有的畫素資料讀出後,寫入新建立的檔案pixels.csv。

資料集分離的程式碼如下:

# 將emotion和pixels畫素資料分離
import pandas as pd

# 注意修改train.csv為你電腦上檔案所在的相對或絕對路勁地址。
path = 'dataset/train.csv'
# 讀取資料
df = pd.read_csv(path)
# 提取emotion資料
df_y = df[['emotion']]
# 提取pixels資料
df_x = df[['pixels']]
# 將emotion寫入emotion.csv
df_y.to_csv('dataset/emotion.csv', index=False, header=False)
# 將pixels資料寫入pixels.csv
df_x.to_csv('dataset/pixels.csv', index=False, header=False)

以上程式碼執行完畢後,在dataset的資料夾下,就會生成兩個新檔案emotion.csv以及pixels.csv。在執行程式碼前,注意修改train.csv為你電腦上檔案所在的相對或絕對路勁地址。

image

3.5 資料視覺化

給定的資料集是csv格式的,考慮到圖片分類問題的常規做法,決定先將其全部視覺化,還原為圖片檔案再送進模型進行處理。

在python環境下,將csv中的畫素資料還原為圖片並儲存下來,有很多庫都能實現類似的功能,如pillow,opencv等。這裡我採用的是用opencv來實現這一功能。

將資料分離後,人臉畫素資料全部儲存在pixels.csv檔案中,其中每行資料就是一張人臉。按行讀取資料,利用opencv將每行的2304個資料恢復為一張48X48的人臉圖片,並儲存為jpg格式。在儲存這些圖片時,將第一行資料恢復出的人臉命名為0.jpg,第二行的人臉命名為1.jpg......,以方便與label[0]、label[1]......一一對應。

資料視覺化的程式碼如下;

import cv2
import numpy as np

# 指定存放圖片的路徑
path = 'face_images'
# 讀取畫素資料
data = np.loadtxt('dataset/pixels.csv')

# 按行取資料
for i in range(data.shape[0]):
    face_array = data[i, :].reshape((48, 48)) # reshape
    cv2.imwrite(path + '//' + '{}.jpg'.format(i), face_array) # 寫圖片

image

以上程式碼雖短,但涉及到大量資料的讀取和大批圖片的寫入,因此佔用的記憶體資源較多,且執行時間較長(視機器效能而定,一般要幾分鐘到十幾分鐘不等)。程式碼執行完畢,我們來到指定的圖片儲存路徑,就能發現裡面全部是寫好的人臉圖片。

image

粗略瀏覽一下這些人臉圖片,就能發現這些圖片資料來源較廣,且並不純淨。就前60張圖片而言,其中就包含了正面人臉,如1.jpg;側面人臉,如18.jpg;傾斜人臉,如16.jpg;正面人頭,如7.jpg;正面人上半身,如55.jpg;動漫人臉,如38.jpg;以及毫不相關的噪聲,如59.jpg。放大圖片後仔細觀察,還會發現不少圖片上還有水印。各種因素均給識別提出了嚴峻的挑戰。

3.6 建立對映表

建立image圖片名和對應emotion表情資料集的對映關係表。

首先,我們需要劃分一下訓練集驗證集。在專案中,共有28709張圖片,取前24000張圖片作為訓練集,其他圖片作為驗證集。新建資料夾train_set和verify_set,將0.jpg到23999.jpg放進資料夾train_set,將其他圖片放進資料夾verify_set。

在繼承torch.utils.data.Dataset類定製自己的資料集時,由於在資料載入過程中需要同時載入出一個樣本的資料及其對應的emotion,因此最好能建立一個image的圖片名和對應emotion表情資料的關係對映表,其中記錄著image的圖片名和其emotion表情資料的對映關係。

這裡需要和大家強調一下:大家在人臉視覺化過程中,每張圖片的命名不是都和emotion的存放順序是一一對應的。在實際操作的過程中才發現,程式載入檔案的機制是按照檔名首字母(或數字)來的,即載入次序是0,1,10,100......,而不是預想中的0,1,2,3......,因此載入出來的圖片不能夠和emotion[0],emotion[1],emotion[2],emotion[3]......一一對應,所以建立image-emotion對映關係表還是相當有必要的。

建立image-emotion對映表的基本思路就是:指定資料夾(train_set或verify_set),遍歷該資料夾下的所有檔案,如果該檔案是.jpg格式的圖片,就將其圖片名寫入一個列表,同時通過圖片名索引出其emotion,將其emotion寫入另一個列表。最後利用pandas庫將這兩個列表寫入同一個csv檔案。

image-emotion關係對映建立程式碼如下:

import os
import pandas as pd

def image_emotion_mapping(path):
    # 讀取emotion檔案
    df_emotion = pd.read_csv('dataset/emotion.csv', header = None)
    # 檢視該資料夾下所有檔案
    files_dir = os.listdir(path)
    # 用於存放圖片名
    path_list = []
    # 用於存放圖片對應的emotion
    emotion_list = []
    # 遍歷該資料夾下的所有檔案
    for file_dir in files_dir:
        # 如果某檔案是圖片,則將其檔名以及對應的emotion取出,分別放入path_list和emotion_list這兩個列表中
        if os.path.splitext(file_dir)[1] == ".jpg":
            path_list.append(file_dir)
            index = int(os.path.splitext(file_dir)[0])
            emotion_list.append(df_emotion.iat[index, 0])

    # 將兩個列表寫進image_emotion.csv檔案
    path_s = pd.Series(path_list)
    emotion_s = pd.Series(emotion_list)
    df = pd.DataFrame()
    df['path'] = path_s
    df['emotion'] = emotion_s
    df.to_csv(path+'\\image_emotion.csv', index=False, header=False)


def main():
    # 指定資料夾路徑
    train_set_path = 'face_images/train_set'
    verify_set_path = 'face_images/verify_set'
    image_emotion_mapping(train_set_path)
    image_emotion_mapping(verify_set_path)

if __name__ == "__main__":
    main()

image

image

image

執行這段程式碼前,注意修改相關檔案路徑。程式碼執行完畢後,會在train_set和verify_set資料夾下各生成一個名為image-emotion.csv的關係對映表。

3.7 載入資料集

現在我們有了圖片,但怎麼才能把圖片讀取出來送給模型呢?一般在平常的時候,我們第一個想到的是將所有需要的資料聚成一堆一堆然後通過構建list去讀取我們的資料:

image

假如我們編寫了上述的影像載入資料集程式碼,在訓練中我們就可以依靠get_training_data()這個函式來得到batch_size個資料,從而進行訓練,乍看下去沒什麼問題,但是一旦我們的資料量超過1000:

  • 將所有的影像資料直接載入到numpy資料中會佔用大量的記憶體
  • 由於需要對資料進行匯入,每次訓練的時候在資料讀取階段會佔用大量的時間
  • 只使用了單執行緒去讀取,讀取效率比較低下
  • 擴充性很差,如果需要對資料進行一些預處理,只能採取一些不是特別優雅的做法

如果用opencv將所有圖片讀取出來,最簡單粗暴的方法就是直接以numpy中array的資料格式直接送給模型。如果這樣做的話,會一次性把所有圖片全部讀入記憶體,佔用大量的記憶體空間,且只能使用單執行緒,效率不高,也不方便後續操作。

既然問題這麼多,到底說回來,我們應該如何正確地載入資料集呢?

其實在pytorch中,有一個類(torch.utils.data.Dataset)是專門用來載入資料的,我們可以通過繼承這個類來定製自己的資料集和載入方法。

Dataset類是Pytorch中影像資料集中最為重要的一個類,也是Pytorch中所有資料集載入類中應該繼承的父類。其中父類中的兩個私有成員函式必須被過載,否則將會觸發錯誤提示:

  • def getitem(self, index):

  • def len(self):

其中__len__應該返回資料集的大小,而__getitem__應該編寫支援資料集索引的函式,例如通過dataset[i]可以得到資料集中的第i+1個資料。

#原始碼
class Dataset(object):
"""An abstract class representing a Dataset.
All other datasets should subclass it. All subclasses should override
``__len__``, that provides the size of the dataset, and ``__getitem__``,
supporting integer indexing in range from 0 to len(self) exclusive.
"""
 
#這個函式就是根據索引,迭代的讀取路徑和標籤。因此我們需要有一個路徑和標籤的 ‘容器’供我們讀
def __getitem__(self, index):
	raise NotImplementedError
 
#返回資料的長度
def __len__(self):
	raise NotImplementedError
def __add__(self, other):
	return ConcatDataset([self, other])

我們通過繼承Dataset類來建立我們自己的資料載入類,命名為FaceDataset,完整程式碼如下:

import torch
from torch.utils import data
import numpy as np
import pandas as pd
import cv2

# 我們通過繼承Dataset類來建立我們自己的資料載入類,命名為FaceDataset
class FaceDataset(data.Dataset):
    '''
    首先要做的是類的初始化。之前的image-emotion對照表已經建立完畢,
    在載入資料時需用到其中的資訊。因此在初始化過程中,我們需要完成對image-emotion對照表中資料的讀取工作。
    通過pandas庫讀取資料,隨後將讀取到的資料放入list或numpy中,方便後期索引。
    '''
    # 初始化
    def __init__(self, root):
        super(FaceDataset, self).__init__()
        self.root = root
        df_path = pd.read_csv(root + '\\image_emotion.csv', header=None, usecols=[0])
        df_label = pd.read_csv(root + '\\image_emotion.csv', header=None, usecols=[1])
        self.path = np.array(df_path)[:, 0]
        self.label = np.array(df_label)[:, 0]

    '''
    接著就要重寫getitem()函式了,該函式的功能是載入資料。
    在前面的初始化部分,我們已經獲取了所有圖片的地址,在這個函式中,我們就要通過地址來讀取資料。
    由於是讀取圖片資料,因此仍然藉助opencv庫。
    需要注意的是,之前視覺化資料部分將畫素值恢復為人臉圖片並儲存,得到的是3通道的灰色圖(每個通道都完全一樣),
    而在這裡我們只需要用到單通道,因此在圖片讀取過程中,即使原圖本來就是灰色的,但我們還是要加入引數從cv2.COLOR_BGR2GARY,
    保證讀出來的資料是單通道的。讀取出來之後,可以考慮進行一些基本的影像處理操作,如通過高斯模糊降噪、通過直方圖均衡化來增強影像等。
    讀出的資料是48X48的,而後續卷積神經網路中nn.Conv2d() API所接受的資料格式是(batch_size, channel, width, higth),
    本次圖片通道為1,因此我們要將48X48 reshape為1X48X48。
    '''

    # 讀取某幅圖片,item為索引號
    def __getitem__(self, item):
        face = cv2.imread(self.root + '\\' + self.path[item])
        # 讀取單通道灰度圖
        face_gray = cv2.cvtColor(face, cv2.COLOR_BGR2GRAY)
        # 高斯模糊
        # face_Gus = cv2.GaussianBlur(face_gray, (3,3), 0)
        # 直方圖均衡化
        face_hist = cv2.equalizeHist(face_gray)
        # 畫素值標準化
        face_normalized = face_hist.reshape(1, 48, 48) / 255.0 # 為與pytorch中卷積神經網路API的設計相適配,需reshape原圖
        # 用於訓練的資料需為tensor型別
        face_tensor = torch.from_numpy(face_normalized) # 將python中的numpy資料型別轉化為pytorch中的tensor資料型別
        face_tensor = face_tensor.type('torch.FloatTensor') # 指定為'torch.FloatTensor'型,否則送進模型後會因資料型別不匹配而報錯
        label = self.label[item]
        return face_tensor, label


    '''
    最後就是重寫len()函式獲取資料集大小了。
    self.path中儲存著所有的圖片名,獲取self.path第一維的大小,即為資料集的大小。
    '''
    # 獲取資料集樣本個數
    def __len__(self):
        return self.path.shape[0]

3.8 網路模型搭建

這裡採用的是基於 CNN 的優化模型,這個模型是源於github一個做表情識別的開源專案,可惜即使借用了這個專案的模型結構,但卻沒能達到源專案中的精度(acc在74%)。下圖為該開源專案中公佈的兩個模型結構,這裡我採用的是 Model B ,且只採用了其中的卷積-全連線部分,如果大家希望進一步提高模型的表現能力,可以參考專案的說明文件,考慮向模型中新增 Face landmarks + HOG features 部分。

開源專案地址:https://github.com/amineHorseman/facial-expression-recognition-using-cnn

image


從下圖我們可以看出,在 Model B 的卷積部分,輸入圖片 shape 為 48X48X1,經過一個3X3X64卷積核的卷積操作,再進行一次 2X 2的池化,得到一個 24X24X64 的 feature map 1(以上卷積和池化操作的步長均為1,每次卷積前的padding為1,下同)。將 feature map 1經過一個 3X3X128 卷積核的卷積操作,再進行一次2X2的池化,得到一個 12X12X128 的 feature map 2。將feature map 2經過一個 3X3X256 卷積核的卷積操作,再進行一次 2X2 的池化,得到一個 6X6X256 的feature map 3。卷積完畢,資料即將進入全連線層。進入全連線層之前,要進行資料扁平化,將feature map 3拉一個成長度為 6X6X256=9216 的一維 tensor。隨後資料經過 dropout 後被送進一層含有4096個神經元的隱層,再次經過 dropout 後被送進一層含有 1024 個神經元的隱層,之後經過一層含 256 個神經元的隱層,最終經過含有7個神經元的輸出層。一般再輸出層後都會加上 softmax 層,取概率最高的類別為分類結果。

image

接著,我們可以通過繼承nn.Module來定義自己的模型類。以下程式碼實現了上述的模型結構。需要注意的是,在程式碼中,資料經過最後含7個神經元的線性層後就直接輸出了,並沒有經過softmax層。這是為什麼呢?其實這和Pytorch在這一塊的設計機制有關。因為在實際應用中,softmax層常常和交叉熵這種損失函式聯合使用,因此Pytorch在設計時,就將softmax運算整合到了交叉熵損失函式CrossEntropyLoss()內部,如果使用交叉熵作為損失函式,就預設在計算損失函式前自動進行softmax操作,不需要我們額外加softmax層。Tensorflow也有類似的機制。

模型程式碼如下:

import torch
import torch.utils.data as data
import torch.nn as nn
import torch.optim as optim
import numpy as np
import pandas as pd
import cv2

# 引數初始化
def gaussian_weights_init(m):
    classname = m.__class__.__name__
    # 字串查詢find,找不到返回-1,不等-1即字串中含有該字元
    if classname.find('Conv') != -1:
        m.weight.data.normal_(0.0, 0.04)


# 驗證模型在驗證集上的正確率
def validate(model, dataset, batch_size):
    val_loader = data.DataLoader(dataset, batch_size)
    result, num = 0.0, 0
    for images, labels in val_loader:
        pred = model.forward(images)
        pred = np.argmax(pred.data.numpy(), axis=1)
        labels = labels.data.numpy()
        result += np.sum((pred == labels))
        num += len(images)
    acc = result / num
    return acc

class FaceCNN(nn.Module):
    # 初始化網路結構
    def __init__(self):
        super(FaceCNN, self).__init__()

        # 第一次卷積、池化
        self.conv1 = nn.Sequential(
            # 輸入通道數in_channels,輸出通道數(即卷積核的通道數)out_channels,卷積核大小kernel_size,步長stride,對稱填0行列數padding
            # input:(bitch_size, 1, 48, 48), output:(bitch_size, 64, 48, 48), (48-3+2*1)/1+1 = 48
            nn.Conv2d(in_channels=1, out_channels=64, kernel_size=3, stride=1, padding=1), # 卷積層
            nn.BatchNorm2d(num_features=64), # 歸一化
            nn.RReLU(inplace=True), # 啟用函式
            # output(bitch_size, 64, 24, 24)
            nn.MaxPool2d(kernel_size=2, stride=2), # 最大值池化
        )

        # 第二次卷積、池化
        self.conv2 = nn.Sequential(
            # input:(bitch_size, 64, 24, 24), output:(bitch_size, 128, 24, 24), (24-3+2*1)/1+1 = 24
            nn.Conv2d(in_channels=64, out_channels=128, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(num_features=128),
            nn.RReLU(inplace=True),
            # output:(bitch_size, 128, 12 ,12)
            nn.MaxPool2d(kernel_size=2, stride=2),
        )

        # 第三次卷積、池化
        self.conv3 = nn.Sequential(
            # input:(bitch_size, 128, 12, 12), output:(bitch_size, 256, 12, 12), (12-3+2*1)/1+1 = 12
            nn.Conv2d(in_channels=128, out_channels=256, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(num_features=256),
            nn.RReLU(inplace=True),
            # output:(bitch_size, 256, 6 ,6)
            nn.MaxPool2d(kernel_size=2, stride=2),
        )

        # 引數初始化
        self.conv1.apply(gaussian_weights_init)
        self.conv2.apply(gaussian_weights_init)
        self.conv3.apply(gaussian_weights_init)

        # 全連線層
        self.fc = nn.Sequential(
            nn.Dropout(p=0.2),
            nn.Linear(in_features=256*6*6, out_features=4096),
            nn.RReLU(inplace=True),
            nn.Dropout(p=0.5),
            nn.Linear(in_features=4096, out_features=1024),
            nn.RReLU(inplace=True),
            nn.Linear(in_features=1024, out_features=256),
            nn.RReLU(inplace=True),
            nn.Linear(in_features=256, out_features=7),
        )

    # 前向傳播
    def forward(self, x):
        x = self.conv1(x)
        x = self.conv2(x)
        x = self.conv3(x)
        # 資料扁平化
        x = x.view(x.shape[0], -1)
        y = self.fc(x)
        return y

有了模型,就可以通過資料的前向傳播和誤差的反向傳播來訓練模型了。在此之前,還需要指定優化器(即學習率更新的方式)、損失函式以及訓練輪數、學習率等超引數。

在本專案中,採用的優化器是SGD,即隨機梯度下降,其中引數weight_decay為正則項係數;損失函式採用的是交叉熵;可以考慮使用學習率衰減。

訓練模型程式碼如下:

def train(train_dataset, val_dataset, batch_size, epochs, learning_rate, wt_decay):
    # 載入資料並分割batch
    train_loader = data.DataLoader(train_dataset, batch_size)
    # 構建模型
    model = FaceCNN()
    # 損失函式
    loss_function = nn.CrossEntropyLoss()
    # 優化器
    optimizer = optim.SGD(model.parameters(), lr=learning_rate, weight_decay=wt_decay)
    # 學習率衰減
    # scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.8)
    # 逐輪訓練
    for epoch in range(epochs):
        # 記錄損失值
        loss_rate = 0
        # scheduler.step() # 學習率衰減
        model.train() # 模型訓練
        for images, emotion in train_loader:
            # 梯度清零
            optimizer.zero_grad()
            # 前向傳播
            output = model.forward(images)
            # 誤差計算
            loss_rate = loss_function(output, emotion)
            # 誤差的反向傳播
            loss_rate.backward()
            # 更新引數
            optimizer.step()

        # 列印每輪的損失
        print('After {} epochs , the loss_rate is : '.format(epoch+1), loss_rate.item())
        if epoch % 5 == 0:
            model.eval() # 模型評估
            acc_train = validate(model, train_dataset, batch_size)
            acc_val = validate(model, val_dataset, batch_size)
            print('After {} epochs , the acc_train is : '.format(epoch+1), acc_train)
            print('After {} epochs , the acc_val is : '.format(epoch+1), acc_val)

    return model

3.9 資料集的使用

image

完整的 model_CNN.py 程式碼如下:

import torch
import torch.utils.data as data
import torch.nn as nn
import torch.optim as optim
import numpy as np
import pandas as pd
import cv2

# 引數初始化
def gaussian_weights_init(m):
    classname = m.__class__.__name__
    # 字串查詢find,找不到返回-1,不等-1即字串中含有該字元
    if classname.find('Conv') != -1:
        m.weight.data.normal_(0.0, 0.04)


# 驗證模型在驗證集上的正確率
def validate(model, dataset, batch_size):
    val_loader = data.DataLoader(dataset, batch_size)
    result, num = 0.0, 0
    for images, labels in val_loader:
        pred = model.forward(images)
        pred = np.argmax(pred.data.numpy(), axis=1)
        labels = labels.data.numpy()
        result += np.sum((pred == labels))
        num += len(images)
    acc = result / num
    return acc

# 我們通過繼承Dataset類來建立我們自己的資料載入類,命名為FaceDataset
class FaceDataset(data.Dataset):
    '''
    首先要做的是類的初始化。之前的image-emotion對照表已經建立完畢,
    在載入資料時需用到其中的資訊。因此在初始化過程中,我們需要完成對image-emotion對照表中資料的讀取工作。
    通過pandas庫讀取資料,隨後將讀取到的資料放入list或numpy中,方便後期索引。
    '''
    # 初始化
    def __init__(self, root):
        super(FaceDataset, self).__init__()
        self.root = root
        df_path = pd.read_csv(root + '\\image_emotion.csv', header=None, usecols=[0])
        df_label = pd.read_csv(root + '\\image_emotion.csv', header=None, usecols=[1])
        self.path = np.array(df_path)[:, 0]
        self.label = np.array(df_label)[:, 0]

    '''
    接著就要重寫getitem()函式了,該函式的功能是載入資料。
    在前面的初始化部分,我們已經獲取了所有圖片的地址,在這個函式中,我們就要通過地址來讀取資料。
    由於是讀取圖片資料,因此仍然藉助opencv庫。
    需要注意的是,之前視覺化資料部分將畫素值恢復為人臉圖片並儲存,得到的是3通道的灰色圖(每個通道都完全一樣),
    而在這裡我們只需要用到單通道,因此在圖片讀取過程中,即使原圖本來就是灰色的,但我們還是要加入引數從cv2.COLOR_BGR2GARY,
    保證讀出來的資料是單通道的。讀取出來之後,可以考慮進行一些基本的影像處理操作,
    如通過高斯模糊降噪、通過直方圖均衡化來增強影像等(經試驗證明,在本專案中,直方圖均衡化並沒有什麼卵用,而高斯降噪甚至會降低正確率,可能是因為圖片解析度本來就較低,模糊後基本上什麼都看不清了吧)。
    讀出的資料是48X48的,而後續卷積神經網路中nn.Conv2d() API所接受的資料格式是(batch_size, channel, width, higth),本次圖片通道為1,因此我們要將48X48 reshape為1X48X48。
    '''

    # 讀取某幅圖片,item為索引號
    def __getitem__(self, item):
        face = cv2.imread(self.root + '\\' + self.path[item])
        # 讀取單通道灰度圖
        face_gray = cv2.cvtColor(face, cv2.COLOR_BGR2GRAY)
        # 高斯模糊
        # face_Gus = cv2.GaussianBlur(face_gray, (3,3), 0)
        # 直方圖均衡化
        face_hist = cv2.equalizeHist(face_gray)
        # 畫素值標準化
        face_normalized = face_hist.reshape(1, 48, 48) / 255.0 # 為與pytorch中卷積神經網路API的設計相適配,需reshape原圖
        # 用於訓練的資料需為tensor型別
        face_tensor = torch.from_numpy(face_normalized) # 將python中的numpy資料型別轉化為pytorch中的tensor資料型別
        face_tensor = face_tensor.type('torch.FloatTensor') # 指定為'torch.FloatTensor'型,否則送進模型後會因資料型別不匹配而報錯
        label = self.label[item]
        return face_tensor, label

    '''
    最後就是重寫len()函式獲取資料集大小了。
    self.path中儲存著所有的圖片名,獲取self.path第一維的大小,即為資料集的大小。
    '''
    # 獲取資料集樣本個數
    def __len__(self):
        return self.path.shape[0]



class FaceCNN(nn.Module):
    # 初始化網路結構
    def __init__(self):
        super(FaceCNN, self).__init__()

        # 第一次卷積、池化
        self.conv1 = nn.Sequential(
            # 輸入通道數in_channels,輸出通道數(即卷積核的通道數)out_channels,卷積核大小kernel_size,步長stride,對稱填0行列數padding
            # input:(bitch_size, 1, 48, 48), output:(bitch_size, 64, 48, 48), (48-3+2*1)/1+1 = 48
            nn.Conv2d(in_channels=1, out_channels=64, kernel_size=3, stride=1, padding=1), # 卷積層
            nn.BatchNorm2d(num_features=64), # 歸一化
            nn.RReLU(inplace=True), # 啟用函式
            # output(bitch_size, 64, 24, 24)
            nn.MaxPool2d(kernel_size=2, stride=2), # 最大值池化
        )

        # 第二次卷積、池化
        self.conv2 = nn.Sequential(
            # input:(bitch_size, 64, 24, 24), output:(bitch_size, 128, 24, 24), (24-3+2*1)/1+1 = 24
            nn.Conv2d(in_channels=64, out_channels=128, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(num_features=128),
            nn.RReLU(inplace=True),
            # output:(bitch_size, 128, 12 ,12)
            nn.MaxPool2d(kernel_size=2, stride=2),
        )

        # 第三次卷積、池化
        self.conv3 = nn.Sequential(
            # input:(bitch_size, 128, 12, 12), output:(bitch_size, 256, 12, 12), (12-3+2*1)/1+1 = 12
            nn.Conv2d(in_channels=128, out_channels=256, kernel_size=3, stride=1, padding=1),
            nn.BatchNorm2d(num_features=256),
            nn.RReLU(inplace=True),
            # output:(bitch_size, 256, 6 ,6)
            nn.MaxPool2d(kernel_size=2, stride=2),
        )

        # 引數初始化
        self.conv1.apply(gaussian_weights_init)
        self.conv2.apply(gaussian_weights_init)
        self.conv3.apply(gaussian_weights_init)

        # 全連線層
        self.fc = nn.Sequential(
            nn.Dropout(p=0.2),
            nn.Linear(in_features=256*6*6, out_features=4096),
            nn.RReLU(inplace=True),
            nn.Dropout(p=0.5),
            nn.Linear(in_features=4096, out_features=1024),
            nn.RReLU(inplace=True),
            nn.Linear(in_features=1024, out_features=256),
            nn.RReLU(inplace=True),
            nn.Linear(in_features=256, out_features=7),
        )

    # 前向傳播
    def forward(self, x):
        x = self.conv1(x)
        x = self.conv2(x)
        x = self.conv3(x)
        # 資料扁平化
        x = x.view(x.shape[0], -1)
        y = self.fc(x)
        return y

def train(train_dataset, val_dataset, batch_size, epochs, learning_rate, wt_decay):
    # 載入資料並分割batch
    train_loader = data.DataLoader(train_dataset, batch_size)
    # 構建模型
    model = FaceCNN()
    # 損失函式
    loss_function = nn.CrossEntropyLoss()
    # 優化器
    optimizer = optim.SGD(model.parameters(), lr=learning_rate, weight_decay=wt_decay)
    # 學習率衰減
    # scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.8)
    # 逐輪訓練
    for epoch in range(epochs):
        # 記錄損失值
        loss_rate = 0
        # scheduler.step() # 學習率衰減
        model.train() # 模型訓練
        for images, emotion in train_loader:
            # 梯度清零
            optimizer.zero_grad()
            # 前向傳播
            output = model.forward(images)
            # 誤差計算
            loss_rate = loss_function(output, emotion)
            # 誤差的反向傳播
            loss_rate.backward()
            # 更新引數
            optimizer.step()

        # 列印每輪的損失
        print('After {} epochs , the loss_rate is : '.format(epoch+1), loss_rate.item())
        if epoch % 5 == 0:
            model.eval() # 模型評估
            acc_train = validate(model, train_dataset, batch_size)
            acc_val = validate(model, val_dataset, batch_size)
            print('After {} epochs , the acc_train is : '.format(epoch+1), acc_train)
            print('After {} epochs , the acc_val is : '.format(epoch+1), acc_val)

    return model

def main():
    # 資料集例項化(建立資料集)
    train_dataset = FaceDataset(root='face_images/train_set')
    val_dataset = FaceDataset(root='face_images/verify_set')
    # 超引數可自行指定
    model = train(train_dataset, val_dataset, batch_size=128, epochs=100, learning_rate=0.1, wt_decay=0)
    # 儲存模型
    torch.save(model, 'model/model_cnn.pkl')


if __name__ == '__main__':
    main()

3.10 儲存模型

執行model_CNN.py模型程式碼

image

生成模型並儲存

image

3.11 模型的測試

3.11.1 載入模型

image

3.11.2 人臉面部表情識別測試

自己測試(看看自己想表達的表情和識別的結果是否一致)

image

image

image

image

image

image

使用視訊進行測試

image

3.12 模型的優化

通過模型的測試,對於自己的面部表情識別,我自己想表達的表情和識別的結果匹配還是有些不一致的(正確率有誤差),以及在視訊測試中,我認為視訊裡的人的表情和實際識別的結果也有差異對於測試中存在的問題,有以下原因:

  • 訓練的資料集還不夠

  • 訓練的模型不夠完善

因此,我們可以用理論部分提出的另外兩種模型VGG模型和ResNet模型,對現有的專案進行優化

3.12.1 採用VGG模型優化

有關VGG模型的程式碼原型,我在templates資料夾下整理好了給大家。資料集Flowers放在了dataset目錄下,大家記得在程式碼原型裡面更改相關路徑的一些配置。
image

接下來,根據VGG模型的原理,我們可以通過繼承nn.Module來定義我們自己的基於VGG的模型類,最後將我們自定義的VGG網路模型進行搭建。

模型的程式碼如下:

class VGG(nn.Module):
    def __init__(self, *args):
        super(VGG, self).__init__()

    def forward(self, x):
        return x.view(x.shape[0],-1)


def vgg_block(num_convs, in_channels, out_channels):
    blk = []
    for i in range(num_convs):
        if i == 0:
            blk.append(nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1))
        else:
            blk.append(nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1))
        blk.append(nn.ReLU())
    blk.append(nn.MaxPool2d(kernel_size=2, stride=2)) # 這裡會使寬高減半
    return nn.Sequential(*blk)
    
    
conv_arch = ((2, 1, 32), (3, 32, 64), (3, 64, 128))
# 經過5個vgg_block, 寬高會減半5次, 變成 224/32 = 7
fc_features = 128 * 6* 6 # c * w * h
fc_hidden_units = 4096 # 任意

def vgg(conv_arch, fc_features, fc_hidden_units):
    net = nn.Sequential()
    # 卷積層部分
    for i, (num_convs, in_channels, out_channels) in enumerate(conv_arch):
        # 每經過一個vgg_block都會使寬高減半
        net.add_module("vgg_block_" + str(i+1), vgg_block(num_convs, in_channels, out_channels))
    # 全連線層部分
    net.add_module("fc", nn.Sequential(
                                 VGG(),
                                 nn.Linear(fc_features, fc_hidden_units),
                                 nn.ReLU(),
                                 nn.Dropout(0.5),
                                 nn.Linear(fc_hidden_units, fc_hidden_units),
                                 nn.ReLU(),
                                 nn.Dropout(0.5),
                                 nn.Linear(fc_hidden_units, 7)
                                ))
    return net

將模型搭建好之後,我們可以來訓練模型了,訓練模型的程式碼如下

train_loss = []
train_ac = []
vaild_loss = []
vaild_ac = []
y_pred = []

def train(model,device,dataset,optimizer,epoch):
    model.train()
    correct = 0
    for i,(x,y) in tqdm(enumerate(dataset)):
        x , y  = x.to(device), y.to(device)
        optimizer.zero_grad()
        output = model(x)
        pred = output.max(1,keepdim=True)[1]
        correct += pred.eq(y.view_as(pred)).sum().item()
        loss = criterion(output,y) 
        loss.backward()
        optimizer.step()   
        
    train_ac.append(correct/len(data_train))   
    train_loss.append(loss.item())
    print("Epoch {} Loss {:.4f} Accuracy {}/{} ({:.0f}%)".format(epoch,loss,correct,len(data_train),100*correct/len(data_train)))

def vaild(model,device,dataset):
    model.eval()
    correct = 0
    with torch.no_grad():
        for i,(x,y) in tqdm(enumerate(dataset)):
            x,y = x.to(device) ,y.to(device)
            output = model(x)
            loss = criterion(output,y)
            pred = output.max(1,keepdim=True)[1]
            global  y_pred 
            y_pred += pred.view(pred.size()[0]).cpu().numpy().tolist()
            correct += pred.eq(y.view_as(pred)).sum().item()
            
    vaild_ac.append(correct/len(data_vaild)) 
    vaild_loss.append(loss.item())
    print("Test Loss {:.4f} Accuracy {}/{} ({:.0f}%)".format(loss,correct,len(data_vaild),100.*correct/len(data_vaild)))

訓練後,我們將訓練好的模型進行儲存,由於我的電腦配置比較拉跨,且用的是CPU,所以訓練模型時間需要特別久,在這裡就不等待模型最終的訓練結果了,大家可以自己去試試。

image

完整的model_VGG.py程式碼如下:

import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
import torchvision
import torchvision.transforms as transforms
from tqdm import tqdm


BATCH_SIZE = 128
LR = 0.01
EPOCH = 60
DEVICE = torch.device('cpu')

path_train = 'face_images/vgg_train_set'
path_vaild = 'face_images/vgg_vaild_set'

transforms_train = transforms.Compose([
    transforms.Grayscale(),#使用ImageFolder預設擴充套件為三通道,重新變回去就行
    transforms.RandomHorizontalFlip(),#隨機翻轉
    transforms.ColorJitter(brightness=0.5, contrast=0.5),#隨機調整亮度和對比度
    transforms.ToTensor()
])
transforms_vaild = transforms.Compose([
    transforms.Grayscale(),
    transforms.ToTensor()
])

data_train = torchvision.datasets.ImageFolder(root=path_train,transform=transforms_train)
data_vaild = torchvision.datasets.ImageFolder(root=path_vaild,transform=transforms_vaild)

train_set = torch.utils.data.DataLoader(dataset=data_train,batch_size=BATCH_SIZE,shuffle=True)
vaild_set = torch.utils.data.DataLoader(dataset=data_vaild,batch_size=BATCH_SIZE,shuffle=False)


class VGG(nn.Module):
    def __init__(self, *args):
        super(VGG, self).__init__()

    def forward(self, x):
        return x.view(x.shape[0],-1)


def vgg_block(num_convs, in_channels, out_channels):
    blk = []
    for i in range(num_convs):
        if i == 0:
            blk.append(nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1))
        else:
            blk.append(nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1))
        blk.append(nn.ReLU())
    blk.append(nn.MaxPool2d(kernel_size=2, stride=2)) # 這裡會使寬高減半
    return nn.Sequential(*blk)
    
    
conv_arch = ((2, 1, 32), (3, 32, 64), (3, 64, 128))
# 經過5個vgg_block, 寬高會減半5次, 變成 224/32 = 7
fc_features = 128 * 6* 6 # c * w * h
fc_hidden_units = 4096 # 任意

def vgg(conv_arch, fc_features, fc_hidden_units):
    net = nn.Sequential()
    # 卷積層部分
    for i, (num_convs, in_channels, out_channels) in enumerate(conv_arch):
        # 每經過一個vgg_block都會使寬高減半
        net.add_module("vgg_block_" + str(i+1), vgg_block(num_convs, in_channels, out_channels))
    # 全連線層部分
    net.add_module("fc", nn.Sequential(
                                 VGG(),
                                 nn.Linear(fc_features, fc_hidden_units),
                                 nn.ReLU(),
                                 nn.Dropout(0.5),
                                 nn.Linear(fc_hidden_units, fc_hidden_units),
                                 nn.ReLU(),
                                 nn.Dropout(0.5),
                                 nn.Linear(fc_hidden_units, 7)
                                ))
    return net

model = vgg(conv_arch, fc_features, fc_hidden_units)
model.to(DEVICE)
optimizer = optim.SGD(model.parameters(),lr=LR,momentum=0.9)
            #optim.Adam(model.parameters())
criterion = nn.CrossEntropyLoss()

train_loss = []
train_ac = []
vaild_loss = []
vaild_ac = []
y_pred = []

def train(model,device,dataset,optimizer,epoch):
    model.train()
    correct = 0
    for i,(x,y) in tqdm(enumerate(dataset)):
        x , y  = x.to(device), y.to(device)
        optimizer.zero_grad()
        output = model(x)
        pred = output.max(1,keepdim=True)[1]
        correct += pred.eq(y.view_as(pred)).sum().item()
        loss = criterion(output,y) 
        loss.backward()
        optimizer.step()   
        
    train_ac.append(correct/len(data_train))   
    train_loss.append(loss.item())
    print("Epoch {} Loss {:.4f} Accuracy {}/{} ({:.0f}%)".format(epoch,loss,correct,len(data_train),100*correct/len(data_train)))

def vaild(model,device,dataset):
    model.eval()
    correct = 0
    with torch.no_grad():
        for i,(x,y) in tqdm(enumerate(dataset)):
            x,y = x.to(device) ,y.to(device)
            output = model(x)
            loss = criterion(output,y)
            pred = output.max(1,keepdim=True)[1]
            global  y_pred 
            y_pred += pred.view(pred.size()[0]).cpu().numpy().tolist()
            correct += pred.eq(y.view_as(pred)).sum().item()
            
    vaild_ac.append(correct/len(data_vaild)) 
    vaild_loss.append(loss.item())
    print("Test Loss {:.4f} Accuracy {}/{} ({:.0f}%)".format(loss,correct,len(data_vaild),100.*correct/len(data_vaild)))

def RUN():
    for epoch in range(1,EPOCH+1):
        '''if epoch==15 :
            LR = 0.1
            optimizer=optimizer = optim.SGD(model.parameters(),lr=LR,momentum=0.9)
        if(epoch>30 and epoch%15==0):
            LR*=0.1
            optimizer=optimizer = optim.SGD(model.parameters(),lr=LR,momentum=0.9)
        '''
        #嘗試動態學習率
        train(model,device=DEVICE,dataset=train_set,optimizer=optimizer,epoch=epoch)
        vaild(model,device=DEVICE,dataset=vaild_set)
        #儲存模型
        torch.save(model,'model/model_vgg.pkl')


if __name__ == '__main__':
    RUN()

最後,我們只需要拿到我們訓練好的model_vgg.pkl模型,放到模型測試程式碼裡面,現實地測試一下(同CNN一樣,測試自己和測試視訊)看看效果如何,將識別結果和實際預期進行對比,看看是否比原來地CNN模型更加準確。(在此不再演示)

完整的model_VGG_test.py程式碼如下:

# -*- coding: utf-8 -*-
import cv2
import torch
import torch.nn as nn
import numpy as np
from statistics import mode


# 人臉資料歸一化,將畫素值從0-255對映到0-1之間
def preprocess_input(images):
    """ preprocess input by substracting the train mean
    # Arguments: images or image of any shape
    # Returns: images or image with substracted train mean (129)
    """
    images = images/255.0
    return images



class VGG(nn.Module):
    def __init__(self, *args):
        super(VGG, self).__init__()

    def forward(self, x):
        return x.view(x.shape[0],-1)


def vgg_block(num_convs, in_channels, out_channels):
    blk = []
    for i in range(num_convs):
        if i == 0:
            blk.append(nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1))
        else:
            blk.append(nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1))
        blk.append(nn.ReLU())
    blk.append(nn.MaxPool2d(kernel_size=2, stride=2)) # 這裡會使寬高減半
    return nn.Sequential(*blk)
    
    
conv_arch = ((2, 1, 32), (3, 32, 64), (3, 64, 128))
# 經過5個vgg_block, 寬高會減半5次, 變成 224/32 = 7
fc_features = 128 * 6* 6 # c * w * h
fc_hidden_units = 4096 # 任意

def vgg(conv_arch, fc_features, fc_hidden_units):
    net = nn.Sequential()
    # 卷積層部分
    for i, (num_convs, in_channels, out_channels) in enumerate(conv_arch):
        # 每經過一個vgg_block都會使寬高減半
        net.add_module("vgg_block_" + str(i+1), vgg_block(num_convs, in_channels, out_channels))
    # 全連線層部分
    net.add_module("fc", nn.Sequential(
                                 VGG(),
                                 nn.Linear(fc_features, fc_hidden_units),
                                 nn.ReLU(),
                                 nn.Dropout(0.5),
                                 nn.Linear(fc_hidden_units, fc_hidden_units),
                                 nn.ReLU(),
                                 nn.Dropout(0.5),
                                 nn.Linear(fc_hidden_units, 7)
                                ))
    return net


#opencv自帶的一個面部識別分類器
detection_model_path = 'model/haarcascade_frontalface_default.xml'

classification_model_path = 'model/model_vgg.pkl'

# 載入人臉檢測模型
face_detection = cv2.CascadeClassifier(detection_model_path)

# 載入表情識別模型
emotion_classifier = torch.load(classification_model_path)


frame_window = 10

#表情標籤
emotion_labels = {0: 'angry', 1: 'disgust', 2: 'fear', 3: 'happy', 4: 'sad', 5: 'surprise', 6: 'neutral'}

emotion_window = []

# 調起攝像頭,0是筆記本自帶攝像頭
video_capture = cv2.VideoCapture(0)
# 視訊檔案識別
# video_capture = cv2.VideoCapture("video/example_dsh.mp4")
font = cv2.FONT_HERSHEY_SIMPLEX
cv2.startWindowThread()
cv2.namedWindow('window_frame')

while True:
    # 讀取一幀
    _, frame = video_capture.read()
    frame = frame[:,::-1,:]#水平翻轉,符合自拍習慣
    frame = frame.copy()
    # 獲得灰度圖,並且在記憶體中建立一個影像物件
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    # 獲取當前幀中的全部人臉
    faces = face_detection.detectMultiScale(gray,1.3,5)
    # 對於所有發現的人臉
    for (x, y, w, h) in faces:
        # 在臉周圍畫一個矩形框,(255,0,0)是顏色,2是線寬
        cv2.rectangle(frame,(x,y),(x+w,y+h),(84,255,159),2)

        # 獲取人臉影像
        face = gray[y:y+h,x:x+w]

        try:
            # shape變為(48,48)
            face = cv2.resize(face,(48,48))
        except:
            continue

        # 擴充維度,shape變為(1,48,48,1)
        #將(1,48,48,1)轉換成為(1,1,48,48)
        face = np.expand_dims(face,0)
        face = np.expand_dims(face,0)

        # 人臉資料歸一化,將畫素值從0-255對映到0-1之間
        face = preprocess_input(face)
        new_face=torch.from_numpy(face)
        new_new_face = new_face.float().requires_grad_(False)
        
        # 呼叫我們訓練好的表情識別模型,預測分類
        emotion_arg = np.argmax(emotion_classifier.forward(new_new_face).detach().numpy())
        emotion = emotion_labels[emotion_arg]

        emotion_window.append(emotion)

        if len(emotion_window) >= frame_window:
            emotion_window.pop(0)

        try:
            # 獲得出現次數最多的分類
            emotion_mode = mode(emotion_window)
        except:
            continue

        # 在矩形框上部,輸出分類文字
        cv2.putText(frame,emotion_mode,(x,y-30), font, .7,(0,0,255),1,cv2.LINE_AA)

    try:
        # 將圖片從記憶體中顯示到螢幕上
        cv2.imshow('window_frame', frame)
    except:
        continue

    # 按q退出
    if cv2.waitKey(1) & 0xFF == ord('q'):
        break

video_capture.release()
cv2.destroyAllWindows()

3.12.2 採用ResNet模型優化

有關ResNet模型的程式碼原型,我也在templates資料夾下整理好了給大家。資料集Flowers放在了dataset目錄下,大家記得在程式碼原型裡面更改相關路徑的一些配置。

image

同樣的,根據ResNet模型的原理,我們可以通過繼承nn.Module來定義我們自己的基於ResNet的模型類,最後將我們自定義的ResNet網路模型進行搭建。

模型的程式碼如下:

class Reshape(nn.Module):
    def __init__(self, *args):
        super(Reshape, self).__init__()

class GlobalAvgPool2d(nn.Module):
    # 全域性平均池化層可通過將池化視窗形狀設定成輸入的高和寬實現
    def __init__(self):
        super(GlobalAvgPool2d, self).__init__()
    def forward(self, x):
        return F.avg_pool2d(x, kernel_size=x.size()[2:])

# 殘差神經網路
class Residual(nn.Module): 
    def __init__(self, in_channels, out_channels, use_1x1conv=False, stride=1):
        super(Residual, self).__init__()
        self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1, stride=stride)
        self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1)
        if use_1x1conv:
            self.conv3 = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride)
        else:
            self.conv3 = None
        self.bn1 = nn.BatchNorm2d(out_channels)
        self.bn2 = nn.BatchNorm2d(out_channels)

    def forward(self, X):
        Y = F.relu(self.bn1(self.conv1(X)))
        Y = self.bn2(self.conv2(Y))
        if self.conv3:
            X = self.conv3(X)
        return F.relu(Y + X)

    
def resnet_block(in_channels, out_channels, num_residuals, first_block=False):
    if first_block:
        assert in_channels == out_channels # 第一個模組的通道數同輸入通道數一致
    blk = []
    for i in range(num_residuals):
        if i == 0 and not first_block:
            blk.append(Residual(in_channels, out_channels, use_1x1conv=True, stride=2))
        else:
            blk.append(Residual(out_channels, out_channels))
    return nn.Sequential(*blk)

resnet = nn.Sequential(
    nn.Conv2d(1, 64, kernel_size=7 , stride=2, padding=3),
    nn.BatchNorm2d(64), 
    nn.ReLU(),
    nn.MaxPool2d(kernel_size=3, stride=2, padding=1))
resnet.add_module("resnet_block1", resnet_block(64, 64, 2, first_block=True))
resnet.add_module("resnet_block2", resnet_block(64, 128, 2))
resnet.add_module("resnet_block3", resnet_block(128, 256, 2))
resnet.add_module("resnet_block4", resnet_block(256, 512, 2))
resnet.add_module("global_avg_pool", GlobalAvgPool2d()) # GlobalAvgPool2d的輸出: (Batch, 512, 1, 1)
resnet.add_module("fc", nn.Sequential(Reshape(), nn.Linear(512, 7))) 

將模型搭建好之後,我們可以來訓練並模型了,步驟和VGG的大同小異,這裡不贅述。由於我的電腦配置比較拉跨,且用的是CPU,所以訓練模型時間需要特別久,在這裡就不等待模型最終的訓練結果了,大家可以自己去試試。

image

完整的model_ResNet.py訓練模型的程式碼如下

import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
import torchvision
import torchvision.transforms as transforms
from tqdm import tqdm



BATCH_SIZE = 128
LR = 0.01
EPOCH = 60
DEVICE = torch.device('cpu')


path_train = 'face_images/resnet_train_set'
path_vaild = 'face_images/resnet_vaild_set'

transforms_train = transforms.Compose([
    transforms.Grayscale(),#使用ImageFolder預設擴充套件為三通道,重新變回去就行
    transforms.RandomHorizontalFlip(),#隨機翻轉
    transforms.ColorJitter(brightness=0.5, contrast=0.5),#隨機調整亮度和對比度
    transforms.ToTensor()
])
transforms_vaild = transforms.Compose([
    transforms.Grayscale(),
    transforms.ToTensor()
])

data_train = torchvision.datasets.ImageFolder(root=path_train,transform=transforms_train)
data_vaild = torchvision.datasets.ImageFolder(root=path_vaild,transform=transforms_vaild)

train_set = torch.utils.data.DataLoader(dataset=data_train,batch_size=BATCH_SIZE,shuffle=True)
vaild_set = torch.utils.data.DataLoader(dataset=data_vaild,batch_size=BATCH_SIZE,shuffle=False)


class ResNet(nn.Module):
    def __init__(self, *args):
        super(ResNet, self).__init__()

    def forward(self, x):
        return x.view(x.shape[0],-1)


class GlobalAvgPool2d(nn.Module):
    # 全域性平均池化層可通過將池化視窗形狀設定成輸入的高和寬實現
    def __init__(self):
        super(GlobalAvgPool2d, self).__init__()
    def forward(self, x):
        return F.avg_pool2d(x, kernel_size=x.size()[2:])


# 殘差神經網路
class Residual(nn.Module): 
    def __init__(self, in_channels, out_channels, use_1x1conv=False, stride=1):
        super(Residual, self).__init__()
        self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1, stride=stride)
        self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1)
        if use_1x1conv:
            self.conv3 = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride)
        else:
            self.conv3 = None
        self.bn1 = nn.BatchNorm2d(out_channels)
        self.bn2 = nn.BatchNorm2d(out_channels)

    def forward(self, X):
        Y = F.relu(self.bn1(self.conv1(X)))
        Y = self.bn2(self.conv2(Y))
        if self.conv3:
            X = self.conv3(X)
        return F.relu(Y + X)

    
def resnet_block(in_channels, out_channels, num_residuals, first_block=False):
    if first_block:
        assert in_channels == out_channels # 第一個模組的通道數同輸入通道數一致
    blk = []
    for i in range(num_residuals):
        if i == 0 and not first_block:
            blk.append(Residual(in_channels, out_channels, use_1x1conv=True, stride=2))
        else:
            blk.append(Residual(out_channels, out_channels))
    return nn.Sequential(*blk)

resnet = nn.Sequential(
    nn.Conv2d(1, 64, kernel_size=7 , stride=2, padding=3),
    nn.BatchNorm2d(64), 
    nn.ReLU(),
    nn.MaxPool2d(kernel_size=3, stride=2, padding=1))
resnet.add_module("resnet_block1", resnet_block(64, 64, 2, first_block=True))
resnet.add_module("resnet_block2", resnet_block(64, 128, 2))
resnet.add_module("resnet_block3", resnet_block(128, 256, 2))
resnet.add_module("resnet_block4", resnet_block(256, 512, 2))
resnet.add_module("global_avg_pool", GlobalAvgPool2d()) # GlobalAvgPool2d的輸出: (Batch, 512, 1, 1)
resnet.add_module("fc", nn.Sequential(ResNet(), nn.Linear(512, 7))) 

model = resnet
model.to(DEVICE)
optimizer = optim.SGD(model.parameters(),lr=LR,momentum=0.9)
            #optim.Adam(model.parameters())
criterion = nn.CrossEntropyLoss()


train_loss = []
train_ac = []
vaild_loss = []
vaild_ac = []
y_pred = []


def train(model,device,dataset,optimizer,epoch):
    model.train()
    correct = 0
    for i,(x,y) in tqdm(enumerate(dataset)):
        x , y  = x.to(device), y.to(device)
        optimizer.zero_grad()
        output = model(x)
        pred = output.max(1,keepdim=True)[1]
        correct += pred.eq(y.view_as(pred)).sum().item()
        loss = criterion(output,y) 
        loss.backward()
        optimizer.step()   
        
    train_ac.append(correct/len(data_train))   
    train_loss.append(loss.item())
    print("Epoch {} Loss {:.4f} Accuracy {}/{} ({:.0f}%)".format(epoch,loss,correct,len(data_train),100*correct/len(data_train)))

def vaild(model,device,dataset):
    model.eval()
    correct = 0
    with torch.no_grad():
        for i,(x,y) in tqdm(enumerate(dataset)):
            x,y = x.to(device) ,y.to(device)
            output = model(x)
            loss = criterion(output,y)
            pred = output.max(1,keepdim=True)[1]
            global  y_pred 
            y_pred += pred.view(pred.size()[0]).cpu().numpy().tolist()
            correct += pred.eq(y.view_as(pred)).sum().item()
            
    vaild_ac.append(correct/len(data_vaild)) 
    vaild_loss.append(loss.item())
    print("Test Loss {:.4f} Accuracy {}/{} ({:.0f}%)".format(loss,correct,len(data_vaild),100.*correct/len(data_vaild)))


def RUN():
    for epoch in range(1,EPOCH+1):
        '''if epoch==15 :
            LR = 0.1
            optimizer=optimizer = optim.SGD(model.parameters(),lr=LR,momentum=0.9)
        if(epoch>30 and epoch%15==0):
            LR*=0.1
            optimizer=optimizer = optim.SGD(model.parameters(),lr=LR,momentum=0.9)
        '''
        #嘗試動態學習率
        train(model,device=DEVICE,dataset=train_set,optimizer=optimizer,epoch=epoch)
        vaild(model,device=DEVICE,dataset=vaild_set)
        torch.save(model,'model/model_resnet.pkl')

if __name__ == '__main__':
    RUN()

最後,我們只需要拿到我們訓練好的model_resnet.pkl模型,放到模型測試程式碼裡面,現實地測試一下(同CNN一樣,測試自己和測試視訊)看看效果如何,將識別結果和實際預期進行對比,看看是否比原來地CNN模型更加準確。(在此不再演示)

完整的model_ResNet_test.py程式碼如下:

# -*- coding: utf-8 -*-
import cv2
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
from statistics import mode


# 人臉資料歸一化,將畫素值從0-255對映到0-1之間
def preprocess_input(images):
    """ preprocess input by substracting the train mean
    # Arguments: images or image of any shape
    # Returns: images or image with substracted train mean (129)
    """
    images = images/255.0
    return images




class ResNet(nn.Module):
    def __init__(self, *args):
        super(ResNet, self).__init__()

    def forward(self, x):
        return x.view(x.shape[0],-1)


class GlobalAvgPool2d(nn.Module):
    # 全域性平均池化層可通過將池化視窗形狀設定成輸入的高和寬實現
    def __init__(self):
        super(GlobalAvgPool2d, self).__init__()
    def forward(self, x):
        return F.avg_pool2d(x, kernel_size=x.size()[2:])


# 殘差神經網路
class Residual(nn.Module): 
    def __init__(self, in_channels, out_channels, use_1x1conv=False, stride=1):
        super(Residual, self).__init__()
        self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1, stride=stride)
        self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1)
        if use_1x1conv:
            self.conv3 = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride)
        else:
            self.conv3 = None
        self.bn1 = nn.BatchNorm2d(out_channels)
        self.bn2 = nn.BatchNorm2d(out_channels)

    def forward(self, X):
        Y = F.relu(self.bn1(self.conv1(X)))
        Y = self.bn2(self.conv2(Y))
        if self.conv3:
            X = self.conv3(X)
        return F.relu(Y + X)

    
def resnet_block(in_channels, out_channels, num_residuals, first_block=False):
    if first_block:
        assert in_channels == out_channels # 第一個模組的通道數同輸入通道數一致
    blk = []
    for i in range(num_residuals):
        if i == 0 and not first_block:
            blk.append(Residual(in_channels, out_channels, use_1x1conv=True, stride=2))
        else:
            blk.append(Residual(out_channels, out_channels))
    return nn.Sequential(*blk)

resnet = nn.Sequential(
    nn.Conv2d(1, 64, kernel_size=7 , stride=2, padding=3),
    nn.BatchNorm2d(64), 
    nn.ReLU(),
    nn.MaxPool2d(kernel_size=3, stride=2, padding=1))
resnet.add_module("resnet_block1", resnet_block(64, 64, 2, first_block=True))
resnet.add_module("resnet_block2", resnet_block(64, 128, 2))
resnet.add_module("resnet_block3", resnet_block(128, 256, 2))
resnet.add_module("resnet_block4", resnet_block(256, 512, 2))
resnet.add_module("global_avg_pool", GlobalAvgPool2d()) # GlobalAvgPool2d的輸出: (Batch, 512, 1, 1)
resnet.add_module("fc", nn.Sequential(ResNet(), nn.Linear(512, 7))) 


#opencv自帶的一個面部識別分類器
detection_model_path = 'model/haarcascade_frontalface_default.xml'

classification_model_path = 'model/model_resnet.pkl'

# 載入人臉檢測模型
face_detection = cv2.CascadeClassifier(detection_model_path)

# 載入表情識別模型
emotion_classifier = torch.load(classification_model_path)


frame_window = 10

#表情標籤
emotion_labels = {0: 'angry', 1: 'disgust', 2: 'fear', 3: 'happy', 4: 'sad', 5: 'surprise', 6: 'neutral'}

emotion_window = []

# 調起攝像頭,0是筆記本自帶攝像頭
video_capture = cv2.VideoCapture(0)
# 視訊檔案識別
# video_capture = cv2.VideoCapture("video/example_dsh.mp4")
font = cv2.FONT_HERSHEY_SIMPLEX
cv2.startWindowThread()
cv2.namedWindow('window_frame')

while True:
    # 讀取一幀
    _, frame = video_capture.read()
    frame = frame[:,::-1,:]#水平翻轉,符合自拍習慣
    frame = frame.copy()
    # 獲得灰度圖,並且在記憶體中建立一個影像物件
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    # 獲取當前幀中的全部人臉
    faces = face_detection.detectMultiScale(gray,1.3,5)
    # 對於所有發現的人臉
    for (x, y, w, h) in faces:
        # 在臉周圍畫一個矩形框,(255,0,0)是顏色,2是線寬
        cv2.rectangle(frame,(x,y),(x+w,y+h),(84,255,159),2)

        # 獲取人臉影像
        face = gray[y:y+h,x:x+w]

        try:
            # shape變為(48,48)
            face = cv2.resize(face,(48,48))
        except:
            continue

        # 擴充維度,shape變為(1,48,48,1)
        #將(1,48,48,1)轉換成為(1,1,48,48)
        face = np.expand_dims(face,0)
        face = np.expand_dims(face,0)

        # 人臉資料歸一化,將畫素值從0-255對映到0-1之間
        face = preprocess_input(face)
        new_face=torch.from_numpy(face)
        new_new_face = new_face.float().requires_grad_(False)
        
        # 呼叫我們訓練好的表情識別模型,預測分類
        emotion_arg = np.argmax(emotion_classifier.forward(new_new_face).detach().numpy())
        emotion = emotion_labels[emotion_arg]

        emotion_window.append(emotion)

        if len(emotion_window) >= frame_window:
            emotion_window.pop(0)

        try:
            # 獲得出現次數最多的分類
            emotion_mode = mode(emotion_window)
        except:
            continue

        # 在矩形框上部,輸出分類文字
        cv2.putText(frame,emotion_mode,(x,y-30), font, .7,(0,0,255),1,cv2.LINE_AA)

    try:
        # 將圖片從記憶體中顯示到螢幕上
        cv2.imshow('window_frame', frame)
    except:
        continue

    # 按q退出
    if cv2.waitKey(1) & 0xFF == ord('q'):
        break

video_capture.release()
cv2.destroyAllWindows()

3.13 模型的對比分析

image image image
CNN VGG ResNet

模型的對比分析完整程式碼如下:


import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
import torchvision
import torchvision.transforms as transforms
import numpy as np
import pandas as pd
from PIL import Image
import os
import matplotlib.pyplot as plt
from tqdm import tqdm



BATCH_SIZE = 128
LR = 0.01
EPOCH = 60
DEVICE = torch.device('cpu')


path_train = '你選定模型的資料集'
path_vaild = '你選定模型的驗證集'

transforms_train = transforms.Compose([
    transforms.Grayscale(),#使用ImageFolder預設擴充套件為三通道,重新變回去就行
    transforms.RandomHorizontalFlip(),#隨機翻轉
    transforms.ColorJitter(brightness=0.5, contrast=0.5),#隨機調整亮度和對比度
    transforms.ToTensor()
])
transforms_vaild = transforms.Compose([
    transforms.Grayscale(),
    transforms.ToTensor()
])

data_train = torchvision.datasets.ImageFolder(root=path_train,transform=transforms_train)
data_vaild = torchvision.datasets.ImageFolder(root=path_vaild,transform=transforms_vaild)

train_set = torch.utils.data.DataLoader(dataset=data_train,batch_size=BATCH_SIZE,shuffle=True)
vaild_set = torch.utils.data.DataLoader(dataset=data_vaild,batch_size=BATCH_SIZE,shuffle=False)


class Reshape(nn.Module):
    def __init__(self, *args):
        super(Reshape, self).__init__()

    def forward(self, x):
        return x.view(x.shape[0],-1)

        
class GlobalAvgPool2d(nn.Module):
    # 全域性平均池化層可通過將池化視窗形狀設定成輸入的高和寬實現
    def __init__(self):
        super(GlobalAvgPool2d, self).__init__()
    def forward(self, x):
        return F.avg_pool2d(x, kernel_size=x.size()[2:])



CNN = nn.Sequential(
    nn.Conv2d(1,64,3),
    nn.ReLU(True),
    nn.MaxPool2d(2,2),
    nn.Conv2d(64,256,3),
    nn.ReLU(True),
    nn.MaxPool2d(3,3),
    Reshape(),
    nn.Linear(256*7*7,4096),
    nn.ReLU(True),
    nn.Linear(4096,1024),
    nn.ReLU(True),
    nn.Linear(1024,7)
    )

def vgg_block(num_convs, in_channels, out_channels):
    blk = []
    for i in range(num_convs):
        if i == 0:
            blk.append(nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1))
        else:
            blk.append(nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1))
        blk.append(nn.ReLU())
    blk.append(nn.MaxPool2d(kernel_size=2, stride=2)) # 這裡會使寬高減半
    return nn.Sequential(*blk)
    
    
conv_arch = ((2, 1, 32), (3, 32, 64), (3, 64, 128))
# 經過5個vgg_block, 寬高會減半5次, 變成 224/32 = 7
fc_features = 128 * 6* 6 # c * w * h
fc_hidden_units = 4096 # 任意

def vgg(conv_arch, fc_features, fc_hidden_units):
    net = nn.Sequential()
    # 卷積層部分
    for i, (num_convs, in_channels, out_channels) in enumerate(conv_arch):
        # 每經過一個vgg_block都會使寬高減半
        net.add_module("vgg_block_" + str(i+1), vgg_block(num_convs, in_channels, out_channels))
    # 全連線層部分
    net.add_module("fc", nn.Sequential(
                                 Reshape(),
                                 nn.Linear(fc_features, fc_hidden_units),
                                 nn.ReLU(),
                                 nn.Dropout(0.5),
                                 nn.Linear(fc_hidden_units, fc_hidden_units),
                                 nn.ReLU(),
                                 nn.Dropout(0.5),
                                 nn.Linear(fc_hidden_units, 7)
                                ))
    return net



# 殘差神經網路
class Residual(nn.Module): 
    def __init__(self, in_channels, out_channels, use_1x1conv=False, stride=1):
        super(Residual, self).__init__()
        self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1, stride=stride)
        self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1)
        if use_1x1conv:
            self.conv3 = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride)
        else:
            self.conv3 = None
        self.bn1 = nn.BatchNorm2d(out_channels)
        self.bn2 = nn.BatchNorm2d(out_channels)

    def forward(self, X):
        Y = F.relu(self.bn1(self.conv1(X)))
        Y = self.bn2(self.conv2(Y))
        if self.conv3:
            X = self.conv3(X)
        return F.relu(Y + X)

    
def resnet_block(in_channels, out_channels, num_residuals, first_block=False):
    if first_block:
        assert in_channels == out_channels # 第一個模組的通道數同輸入通道數一致
    blk = []
    for i in range(num_residuals):
        if i == 0 and not first_block:
            blk.append(Residual(in_channels, out_channels, use_1x1conv=True, stride=2))
        else:
            blk.append(Residual(out_channels, out_channels))
    return nn.Sequential(*blk)

resnet = nn.Sequential(
    nn.Conv2d(1, 64, kernel_size=7 , stride=2, padding=3),
    nn.BatchNorm2d(64), 
    nn.ReLU(),
    nn.MaxPool2d(kernel_size=3, stride=2, padding=1))
resnet.add_module("resnet_block1", resnet_block(64, 64, 2, first_block=True))
resnet.add_module("resnet_block2", resnet_block(64, 128, 2))
resnet.add_module("resnet_block3", resnet_block(128, 256, 2))
resnet.add_module("resnet_block4", resnet_block(256, 512, 2))
resnet.add_module("global_avg_pool", GlobalAvgPool2d()) # GlobalAvgPool2d的輸出: (Batch, 512, 1, 1)
resnet.add_module("fc", nn.Sequential(Reshape(), nn.Linear(512, 7))) 

# 用那個模型就切換註釋即可
model = CNN
#model = resnet
#model = vgg(conv_arch, fc_features, fc_hidden_units)
model.to(DEVICE)
optimizer = optim.SGD(model.parameters(),lr=LR,momentum=0.9)
            #optim.Adam(model.parameters())
criterion = nn.CrossEntropyLoss()

print(model)

train_loss = []
train_ac = []
vaild_loss = []
vaild_ac = []
y_pred = []

def train(model,device,dataset,optimizer,epoch):
    model.train()
    correct = 0
    for i,(x,y) in tqdm(enumerate(dataset)):
        x , y  = x.to(device), y.to(device)
        optimizer.zero_grad()
        output = model(x)
        pred = output.max(1,keepdim=True)[1]
        correct += pred.eq(y.view_as(pred)).sum().item()
        loss = criterion(output,y) 
        loss.backward()
        optimizer.step()   
        
    train_ac.append(correct/len(data_train))   
    train_loss.append(loss.item())
    print("Epoch {} Loss {:.4f} Accuracy {}/{} ({:.0f}%)".format(epoch,loss,correct,len(data_train),100*correct/len(data_train)))

def vaild(model,device,dataset):
    model.eval()
    correct = 0
    with torch.no_grad():
        for i,(x,y) in tqdm(enumerate(dataset)):
            x,y = x.to(device) ,y.to(device)
            output = model(x)
            loss = criterion(output,y)
            pred = output.max(1,keepdim=True)[1]
            global  y_pred 
            y_pred += pred.view(pred.size()[0]).cpu().numpy().tolist()
            correct += pred.eq(y.view_as(pred)).sum().item()
            
    vaild_ac.append(correct/len(data_vaild)) 
    vaild_loss.append(loss.item())
    print("Test Loss {:.4f} Accuracy {}/{} ({:.0f}%)".format(loss,correct,len(data_vaild),100.*correct/len(data_vaild)))


def RUN():
    for epoch in range(1,EPOCH+1):
        '''if epoch==15 :
            LR = 0.1
            optimizer=optimizer = optim.SGD(model.parameters(),lr=LR,momentum=0.9)
        if(epoch>30 and epoch%15==0):
            LR*=0.1
            optimizer=optimizer = optim.SGD(model.parameters(),lr=LR,momentum=0.9)
        '''
        #嘗試動態學習率
        train(model,device=DEVICE,dataset=train_set,optimizer=optimizer,epoch=epoch)
        vaild(model,device=DEVICE,dataset=vaild_set)
        torch.save(model,'m0.pth')

RUN()        
#vaild(model,device=DEVICE,dataset=vaild_set)
 
def print_plot(train_plot,vaild_plot,train_text,vaild_text,ac,name):  
    x= [i for i in range(1,len(train_plot)+1)]
    plt.plot(x,train_plot,label=train_text)
    plt.plot(x[-1],train_plot[-1],marker='o')
    plt.annotate("%.2f%%"%(train_plot[-1]*100) if ac else "%.4f"%(train_plot[-1]),xy=(x[-1],train_plot[-1]))
    plt.plot(x,vaild_plot,label=vaild_text)
    plt.plot(x[-1],vaild_plot[-1],marker='o')
    plt.annotate("%.2f%%"%(vaild_plot[-1]*100) if ac else "%.4f"%(vaild_plot[-1]),xy=(x[-1],vaild_plot[-1]))
    plt.legend()
    plt.savefig(name)
    
#print_plot(train_loss,vaild_loss,"train_loss","vaild_loss",False,"loss.jpg")
#print_plot(train_ac,vaild_ac,"train_ac","vaild_ac",True,"ac.jpg")

import seaborn as sns
from sklearn.metrics import confusion_matrix

emotion = ["angry","disgust","fear","happy","sad","surprised","neutral"]
sns.set()
f,ax=plt.subplots()
y_true = [ emotion[i] for _,i in data_vaild]
y_pred = [emotion[i] for i in y_pred]
C2= confusion_matrix(y_true, y_pred, labels=["angry","disgust","fear","happy","sad","surprised","neutral"])#[0, 1, 2,3,4,5,6])
#print(C2) #列印出來看看
sns.heatmap(C2,annot=True ,fmt='.20g',ax=ax) #熱力圖

ax.set_title('confusion matrix') #標題
ax.set_xlabel('predict') #x軸
ax.set_ylabel('true') #y軸
plt.savefig('matrix.jpg')

image

如果你還想看更多的不同模型的訓練結果,以及相關資料讀取,處理與分析,可以去FER2013資料集官網下載別人的程式碼參考學習,除了看不同模型的對比分析,還能學習其他有關深度學習與計算機視覺的知識(如基於Tensorflow的實現等)

image

image

這裡我也給大家下載了一些模板參考:(放在了templates資料夾下)

image

image

image

image

image

四、總結

這是我校本課程選修課程深度學習與計算機視覺期末大作業。本次作業參考學習了很多文章的經驗與方法,自己也試著將其歸納總結。完成此次作業也可是不易,但也鍛鍊了自己的學習能力,雖然有些知識自己還不能夠非常能完全掌握理解,但我相信,在後續的學習中,自己對這方面的知識的理解也會加強許多,學無止境,希望和大家一起加油進步吧!

本篇文章引用的資料都已在文末給出,如果需要轉載此文,別忘了加上轉載標籤地址哦!如果文章中有什麼不對的地方,歡迎大家幫忙指出糾正!

本篇文章中的程式碼全部已經開源在了我的GitHub和Gitee上,歡迎大家前來clone和star!!!

GitHub地址:https://github.com/He-Xiang-best/Facial-Expression-Recognition

Gitee地址:https://gitee.com/hexiang_home/Facial-Expression-Recognition

本專案的資料集下載地址:https://download.csdn.net/download/HXBest/64847238

本專案的模型檔案下載地址:https://download.csdn.net/download/HXBest/64955910

五、參考文獻

資料集相關:

1、FER2013資料集官網:https://www.kaggle.com/c/challenges-in-representation-learning-facial-expression-recognition-challenge/data

2、Pytorch中正確設計並載入資料集方法:https://ptorch.com/news/215.html

3、pytorch載入自己的影像資料集例項:http://www.cppcns.com/jiaoben/python/324744.html

4、python中的影像處理框架進行影像的讀取和基本變換:https://oldpan.me/archives/pytorch-transforms-opencv-scikit-image


CNN相關:

1、常見CNN網路結構詳解:https://blog.csdn.net/u012897374/article/details/79199935?spm=1001.2014.3001.5506

2、基於CNN優化模型的開源專案地址:https://github.com/amineHorseman/facial-expression-recognition-using-cnn

3、A CNN based pytorch implementation on facial expression recognition (FER2013 and CK+), achieving 73.112% (state-of-the-art) in FER2013 and 94.64% in CK+ dataset:https://github.com/WuJie1010/Facial-Expression-Recognition.Pytorch


VGG相關:

1、VGG論文地址:https://arxiv.org/pdf/1409.1556.pdf

2、VGG模型詳解及程式碼分析:https://blog.csdn.net/weixin_45225975/article/details/109220154#18c8c548-e4c5-24bb-e255-cd1c471af2ff


ResNet相關:

1、ResNet論文地址:https://arxiv.org/pdf/1512.03385.pdf

2、ResNet模型詳解及程式碼分析:https://blog.csdn.net/weixin_44023658/article/details/105843701

3、Batch Normalization(BN)超詳細解析:https://blog.csdn.net/weixin_44023658/article/details/105844861


表情識別相關:

1、基於卷積神經網路的面部表情識別(Pytorch實現):https://www.cnblogs.com/HL-space/p/10888556.html

2、Fer2013 表情識別 pytorch (CNN、VGG、Resnet):https://www.cnblogs.com/weiba180/p/12600259.html#resnet

3、OpenCV 使用 pytorch 模型 通過攝像頭實時表情識別:https://www.cnblogs.com/weiba180/p/12613764.html