caffe源碼深入學習6:超級詳細的im2col繪圖解析,分析caffe卷積操作的底層實現

   在先前的兩篇博客中,筆者詳細解析了caffe卷積層的定義與實現,可是在conv_layer.cpp與base_conv_layer.cpp中,卷積操作的實現仍然被隱藏,通過im2col_cpu函數和caffe_cpu_gemm函數(後者實現矩陣乘法)實現,在此篇博客中,筆者旨在向大家展示,caffe的卷積操作是如何高效地通過向量轉化和矩陣相乘完成的,並向大家解析caffe中的im2col操作,下面我們開始正文:

   首先回顧一下卷積的實現理論細節,卷積核是一個小窗口(記錄權重),在輸入圖像上按步長滑動,每次滑動操作輸入圖像上的對應小窗區域,將卷積核中的各個權值與輸入圖像上對應小窗口中的各個值相乘,然後相加,並加上偏置得到輸出特徵圖上的一個值,見下圖(圖片來自網絡)


   請各位讀者朋友思考,卷積覈對輸入特徵圖的每一次運算,是不是與兩個向量的內積非常類似?這就意味着,卷積操作完全可以轉化爲矩陣的乘法來實現,事實上,caffe也是這麼做的,卷積層對每一個blob的卷積操作可以看成權值矩陣與輸入特徵圖轉化成的矩陣進行相乘的運算,其中,權值矩陣的行數爲一個卷積組的輸出通道數,權值矩陣的列數爲一個卷積組的輸入通道數*卷積核高*卷積核寬;而輸入特徵圖轉化成的矩陣行數爲一個卷積組的輸入通道數*卷積核高*卷積核寬,列數爲卷積層輸出的單通道特徵圖高*卷積層輸出的單通道特徵圖寬。

   接下來筆者用式子清晰地表示一下caffe卷積操作的實現:

卷積層輸出 = 權值矩陣 * 輸入特徵圖轉化得到的矩陣

權值矩陣尺度 = (卷積組輸出通道數) * (卷積組輸入通道數*卷積核高*卷積核寬)

輸入特徵圖轉化得到的矩陣尺度 = (卷積組輸入通道數*卷積核高*卷積核寬) * (卷積層輸出單通道特徵圖高 * 卷積層輸出單通道特徵圖寬)

因此,卷積層輸出尺度可以表示爲

卷積層輸出尺度 = (卷積層輸出通道數) * (卷積層輸出單通道特徵圖高 * 卷積層輸出單通道特徵圖寬)

   到此是不是可以看到,卷積層輸出尺度正好是理論上的卷積輸出尺度。那麼,在這個卷積乘法中,權值矩陣與輸入特徵圖轉化得到的矩陣是怎麼得來的呢?這就是im2col.cpp中定義的了,也是本篇博客筆者解析的重點,下面筆者將以一張卷積層輸入的單通道特徵圖爲例,解析一下是通過怎樣的操作生成相應的矩陣的。

   首先給出is_a_ge_zero_and_a_lt_b函數的定義及註釋:

// Function uses casting from int to unsigned to compare if value of
// parameter a is greater or equal to zero and lower than value of
// parameter b. The b parameter is of type signed and is always positive,
// therefore its value is always lower than 0x800... where casting
// negative value of a parameter converts it to value higher than 0x800...
// The casting allows to use one condition instead of two.
inline bool is_a_ge_zero_and_a_lt_b(int a, int b) {//若a大於等於零或小於b,返回true,否則返回false
  return static_cast<unsigned>(a) < static_cast<unsigned>(b);
}
該函數定義是:若a大於0且嚴格小於b,則返回真,否則返回假,該函數的作用是判斷矩陣上某元的輸出是否爲pad的0。

   然後給出im2col_cpu函數定義及註釋:

/*im2col_cpu將c個通道的卷積層輸入圖像轉化爲c個通道的矩陣,矩陣的行值爲卷積核高*卷積核寬,
也就是說,矩陣的單列表徵了卷積核操作一次處理的小窗口圖像信息;而矩陣的列值爲卷積層
輸出單通道圖像高*卷積層輸出單通道圖像寬,表示一共要處理多少個小窗口。
im2col_cpu接收13個參數,分別爲輸入數據指針(data_im),卷積操作處理的一個卷積組的通道
數(channels),輸入圖像的高(height)與寬(width),原始卷積核的高(kernel_h)與寬(kernel_w),
輸入圖像高(pad_h)與寬(pad_w)方向的pad,卷積操作高(stride_h)與寬(stride_w)方向的步長,
卷積核高(stride_h)與寬(stride_h)方向的擴展,輸出矩陣數據指針(data_col)*/
template <typename Dtype>
void im2col_cpu(const Dtype* data_im, const int channels,
    const int height, const int width, const int kernel_h, const int kernel_w,
    const int pad_h, const int pad_w,
    const int stride_h, const int stride_w,
    const int dilation_h, const int dilation_w,
    Dtype* data_col) {
  const int output_h = (height + 2 * pad_h -
    (dilation_h * (kernel_h - 1) + 1)) / stride_h + 1;//計算卷積層輸出圖像的高
  const int output_w = (width + 2 * pad_w -
    (dilation_w * (kernel_w - 1) + 1)) / stride_w + 1;//計算卷積層輸出圖像的寬
  const int channel_size = height * width;//計算卷積層輸入單通道圖像的數據容量
  /*第一個for循環表示輸出的矩陣通道數和卷積層輸入圖像通道是一樣的,每次處理一個輸入通道的信息*/
  for (int channel = channels; channel--; data_im += channel_size) {
	/*第二個和第三個for循環表示了輸出單通道矩陣的某一列,同時體現了輸出單通道矩陣的行數*/
    for (int kernel_row = 0; kernel_row < kernel_h; kernel_row++) {
      for (int kernel_col = 0; kernel_col < kernel_w; kernel_col++) {
        int input_row = -pad_h + kernel_row * dilation_h;//在這裏找到卷積核中的某一行在輸入圖像中的第一個操作區域的行索引
		/*第四個和第五個for循環表示了輸出單通道矩陣的某一行,同時體現了輸出單通道矩陣的列數*/
        for (int output_rows = output_h; output_rows; output_rows--) {
          if (!is_a_ge_zero_and_a_lt_b(input_row, height)) {//如果計算得到的輸入圖像的行值索引小於零或者大於輸入圖像的高(該行爲pad)
            for (int output_cols = output_w; output_cols; output_cols--) {
              *(data_col++) = 0;//那麼將該行在輸出的矩陣上的位置置爲0
            }
          } else {
            int input_col = -pad_w + kernel_col * dilation_w;//在這裏找到卷積核中的某一列在輸入圖像中的第一個操作區域的列索引
            for (int output_col = output_w; output_col; output_col--) {
              if (is_a_ge_zero_and_a_lt_b(input_col, width)) {//如果計算得到的輸入圖像的列值索引大於等於於零或者小於輸入圖像的寬(該列不是pad)
                *(data_col++) = data_im[input_row * width + input_col];//將輸入特徵圖上對應的區域放到輸出矩陣上
              } else {//否則,計算得到的輸入圖像的列值索引小於零或者大於輸入圖像的寬(該列爲pad)
                *(data_col++) = 0;//將該行該列在輸出矩陣上的位置置爲0
              }
              input_col += stride_w;//按照寬方向步長遍歷卷積核上固定列在輸入圖像上滑動操作的區域
            }
          }
          input_row += stride_h;//按照高方向步長遍歷卷積核上固定行在輸入圖像上滑動操作的區域
        }
      }
    }
  }
}

   im2col_cpu函數將卷積層輸入轉化爲矩陣相乘的右元,核心是5個for循環,首先第一個for循環表示按照輸入的通道數逐個處理卷積層輸入的特徵圖,下面筆者將用圖示表示剩餘的四個for循環操作,向讀者朋友們展示卷積層輸入的單通道特徵圖是通過怎樣的方式轉化爲一個矩陣。在這裏我們假設,卷積層輸入單通道特徵圖原大小爲5*5,高和寬方向的pad爲1,高和寬方向步長爲2,卷積核不進行擴展。

   我們先計算一下,卷積層輸入單通道特徵圖轉化得到的矩陣的尺度,矩陣的行數應該爲卷積核高*卷積核寬,即爲9,列數應該爲卷積層輸出特徵圖高(output_h)*卷積層輸出特徵圖寬(output_w),也爲9,那麼,im2col算法起始由下圖開始:


   首先kernel_row爲0,kernel_col也爲0。按照input_row = -pad_h + kernel_row * dilation_h計算input_row的值,在這裏,pad_h爲1,kernel_row爲0,dilation_h爲1,計算出input_row爲-1,此時output_row爲3,滿足函數中的第一個if條件,那麼在輸出圖像上先置output_w個零,因爲output_w爲3,因此得到下圖:


然後input_row加上步長2,由-1變成1,此時output_rows爲2,計算input_col等於-1,此時執行input_col定義下面的for循環,得到3個值:依次往目標矩陣中填入0,data_im[1*5+1]和data_im[1*5+3],即填入0,7和9。得到下圖:


再接着執行,此時input_row再加上2變爲3,此時output_rows變爲1,計算input_col等於-1,執行input_col定義下面的for循環,得到3個值,分別爲0,data_im[3*5+1]和data_im[3*5+3],即填入0,17和19。得到下圖:


   接着,kernel_col變成1,此時kernel_row爲0,kernel_col爲1。計算input_row又變成-1,第一個if條件成立,那麼,再在輸出矩陣上輸出3個0。然後,input_row變成1,input_col分別爲0(-1+1),2(-1+1+2)和4(-1+1+2+2)時,輸出矩陣上分別輸出data_im[1*5+0],data[1*5+2],data[1*5+4],即分別填入6,8,10。然後,input_row變成3,input_col分別爲0,2,4時,輸出矩陣上分別輸出data_im[3*5+0],data[3*5+2],data[3*5+4],即分別輸出16,18,20。

   然後,kernel_col變成2,此時kernel_row爲0,kernel_col爲2。計算input_row又變成-1,第一個if條件成立,那麼,再在輸出矩陣上輸出3個0。然後,input_row變成1,input_col分別爲1(-1+2),3(-1+2+2)和5(-1+2+2+2)時,輸出矩陣上分別輸出data_im[1*5+1],data[1*5+3],0,即分別填入7,9,0。然後,input_row變成3,input_col分別爲1,3,5時,輸出矩陣上分別輸出data_im[3*5+0],data[3*5+2],0,即分別輸出17,19,0。見下圖:


   接着,kernel_row變成1,kernel_col變成0。計算input_row又變成0,input_col分別爲-1(-1+0),1(-1+0+2)和3(-1+0+2+2),輸出矩陣上分別輸出0,data[0*5+1],data[0*5+3],即分別填入0,2,4。然後,input_row變成2,input_col分別爲-1,1和3時,輸出矩陣上分別輸出0,data[2*5+1],data[2*5+3],即分別填入0,12,14。然後,input_row變成4,input_col分別爲-1,1,3時,輸出矩陣上分別輸出0,data[4*5+1],data[4*5+3],即分別輸出0,22,24。見下圖:


   然後,kernel_row爲1,kernel_col變成1。計算input_row爲0,input_col分別爲0(-1+1),2(-1+1+2)和4(-1+1+2+2),輸出矩陣上分別輸出data[0*5+0],data[0*5+2],data[0*5+4],即分別填入1,3,5。然後,input_row變成2,input_col分別爲0,2和4時,輸出矩陣上分別輸出data[2*5+0],data[2*5+2],data[2*5+4],即分別填入11,13,15。然後,input_row變成4,input_col分別爲0,2,4時,輸出矩陣上分別輸出data[4*5+0],data[4*5+2],data[4*5+4],即分別輸出21,23,25。見下圖:


   然後,kernel_row爲1,kernel_col變成2。計算input_row爲0,input_col分別爲1(-1+2),3(-1+2+2)和5(-1+2+2+2),輸出矩陣上分別輸出data[0*5+1],data[0*5+3],0,即分別填入2,4,0。然後,input_row變成2,input_col分別爲1,3和5時,輸出矩陣上分別輸出data[2*5+1],data[2*5+3],0,即分別填入12,14,0。然後,input_row變成4,input_col分別爲1,3,5時,輸出矩陣上分別輸出data[4*5+1],data[4*5+3],0,即分別輸出22,24,0。見下圖:


   接着,kernel_row變成2,kernel_col變成0。計算input_row爲1,input_col分別爲-1(-1+0),1(-1+0+2)和3(-1+0+2+2),輸出矩陣上分別輸出0,data[1*5+1],data[1*5+3],即分別填入0,7,9。然後,input_row變成3,input_col分別爲-1,1和3時,輸出矩陣上分別輸出0,data[3*5+1],data[3*5+3],即分別填入0,17,19。然後,input_row變成5,滿足第一個if條件,直接輸出三個0。見下圖:


   然後,kernel_row爲2,kernel_col變成1。計算input_row爲1,input_col分別爲0(-1+1),2(-1+1+2)和4(-1+1+2+2),輸出矩陣上分別輸出data[1*5+0],data[1*5+2],data[1*5+4],即分別填入6,8,10。然後,input_row變成3,input_col分別爲0,2和4時,輸出矩陣上分別輸出data[3*5+0],data[3*5+2],data[3*5+4],即分別填入16,18,20。然後,input_row變成5,滿足第一個if條件,直接輸出三個0。見下圖:


   最後,kernel_row爲2,kernel_col變成2。計算input_row爲1,input_col分別爲1(-1+2),3(-1+2+2)和5(-1+2+2+2),輸出矩陣上分別輸出data[1*5+1],data[1*5+3],0,即分別填入7,9,0。然後,input_row變成3,input_col分別爲1,3和5時,輸出矩陣上分別輸出data[3*5+1],data[3*5+3],0,即分別填入17,19,0。然後,input_row變成5,滿足第一個if條件,直接輸出三個0。見下圖:


   到此卷積層單通道輸入特徵圖就轉化成了一個矩陣,請讀者朋友們仔細看看,矩陣的各列是不是卷積核操作的各小窗口呢?


   筆者還想提醒大家的是,注意卷積中的zero-pad操作的實現,並不是真正在原始輸入特徵圖周圍添加0,而是在特徵圖轉化得到的矩陣上的對應位置添加0。

   而im2col_cpu函數功能的相反方向的實現則有由col2im_cpu函數完成,筆者依舊把該函數的代碼註釋放在下面:

/*col2im_cpu爲im2col_cpu的逆操作接收13個參數,分別爲輸入矩陣數據指針(data_col),卷積操作處理的一個卷積組的通道
數(channels),輸入圖像的高(height)與寬(width),原始卷積核的高(kernel_h)與寬(kernel_w),
輸入圖像高(pad_h)與寬(pad_w)方向的pad,卷積操作高(stride_h)與寬(stride_w)方向的步長,
卷積核高(stride_h)與寬(stride_h)方向的擴展,輸出圖像數據指針(data_im)*/
template <typename Dtype>
void col2im_cpu(const Dtype* data_col, const int channels,
    const int height, const int width, const int kernel_h, const int kernel_w,
    const int pad_h, const int pad_w,
    const int stride_h, const int stride_w,
    const int dilation_h, const int dilation_w,
    Dtype* data_im) {
  caffe_set(height * width * channels, Dtype(0), data_im);//首先對輸出的區域進行初始化,全部填充0
  const int output_h = (height + 2 * pad_h -
    (dilation_h * (kernel_h - 1) + 1)) / stride_h + 1;//計算卷積層輸出圖像的寬
  const int output_w = (width + 2 * pad_w -
    (dilation_w * (kernel_w - 1) + 1)) / stride_w + 1;//計算卷積層輸出圖像的高
  const int channel_size = height * width;//col2im輸出的單通道圖像容量
  for (int channel = channels; channel--; data_im += channel_size) {//按照輸出通道數一個一個處理
    for (int kernel_row = 0; kernel_row < kernel_h; kernel_row++) {
      for (int kernel_col = 0; kernel_col < kernel_w; kernel_col++) {
        int input_row = -pad_h + kernel_row * dilation_h;//在這裏找到卷積核中的某一行在輸入圖像中的第一個操作區域的行索引
        for (int output_rows = output_h; output_rows; output_rows--) {
          if (!is_a_ge_zero_and_a_lt_b(input_row, height)) {//如果計算得到的輸入圖像的行值索引小於零或者大於輸入圖像的高(該行爲pad)
            data_col += output_w;//那麼,直接跳過這output_w個數,這些數是輸入圖像第一行上面或者最後一行下面pad的0
          } else {
            int input_col = -pad_w + kernel_col * dilation_w;//在這裏找到卷積核中的某一列在輸入圖像中的第一個操作區域的列索引
            for (int output_col = output_w; output_col; output_col--) {
              if (is_a_ge_zero_and_a_lt_b(input_col, width)) {//如果計算得到的輸入圖像的列值索引大於等於於零或者小於輸入圖像的寬(該列不是pad)
                data_im[input_row * width + input_col] += *data_col;//將矩陣上對應的元放到將要輸出的圖像上
              }//這裏沒有else,因爲如果緊挨的if條件不成立的話,input_row * width + input_col這個下標在data_im中不存在,同時遍歷到data_col的對應元爲0
              data_col++;//遍歷下一個data_col中的數
              input_col += stride_w;//按照寬方向步長遍歷卷積核上固定列在輸入圖像上滑動操作的區域
            }
          }
          input_row += stride_h;//按照高方向步長遍歷卷積核上固定行在輸入圖像上滑動操作的區域
        }
      }
    }
  }
}

   到此,im2col.cpp中的核心函數就已經解析完畢了,筆者在最開始閱讀這個源碼的時候,也沒有弄得太明白,可是經過仔細畫圖推敲,明白了其中的含義。從這件小事可以看出,光看不練假把式,在閱讀源碼時,遇到功能實現中比較抽象的部分,應該再仔細思考分析的同時,多動筆桿,切勿偷懶!

   到此,caffe的卷積層解析完畢了,在下一篇解析caffe源碼的博客中,筆者打算解析一下caffe的數據層,分析數據是通過怎樣的方式送入網絡的,歡迎閱讀筆者後續解析caffe源碼的博客,各位讀者朋友的支持與鼓勵是我最大的動力!



written by jiong

選擇奮鬥!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章