1. 程式人生 > >caffe原始碼深入學習6:超級詳細的im2col繪圖解析,分析caffe卷積操作的底層實現

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原始碼的部落格,各位讀者朋友的支援與鼓勵是我最大的動力!
--------------------- 
作者:jiongnima 
來源:CSDN 
原文:https://blog.csdn.net/jiongnima/article/details/69736844?utm_source=copy 
版權宣告:本文為博主原創文章,轉載請附上博文連結!