天天看點

搞定目标檢測(SSD篇)(下)

搞定目标檢測(SSD篇)(下)

搞定目标檢測(SSD篇)(上)主要分析了目标檢測的基本原理和技術局限,本文将繼續上集的未盡事宜,詳解如何使用SSD搞定目标檢測。先打個預防針,本文的内容會比較燒腦,最好結合代碼和論文來了解,而且本文的閱讀前提是預設你已經掌握了上集的内容,當然我也會盡量用通俗易懂的語言給你講清楚。Github: https://github.com/alexshuang/pascal-voc-pytorch。

SSD / Paper / Notebook

首先,我想先從SSD的副标題:“Single Shot MultiBox Detector”入手,用上帝視角帶你從宏觀上了解它:

  • “Single Shot”指的是單目标檢測。
  • “Box”就像是拍攝用的取景框,“Single Shot”的範圍隻限于框内,框外的内容一律屏蔽。
  • “MultiBox”指的是用各種不同大小、形狀的取景框覆寫整個圖像。

綜合所有因素就能得出SSD的工作原理:将圖像切分為N個區域,對每個區域進行單目标檢測,并彙總所有的單目标檢測結果。

搞定目标檢測(SSD篇)(下)

SSD是用Convolution來切分圖像的。正如它的網絡架構所示,SSD的top layers(Extra Feature Layers)由多個卷積層組成。假設某個卷積層的計算結果是:[64, 25, 4, 4],它指的是在4x4大小的feature map中,總共有16個grid cells,每個cell映射到圖像中的一個區域。

搞定目标檢測(SSD篇)(下)

如Figure 2所示,圖像中的網格就是grid cells映射的區域,即MultiBox。MultiBox的單目标檢測預測結果就儲存在卷積矩陣的axis 1(channels次元),25 = bounding box + 分類機率 = 4 + 21(20 + “Background”)。

SSD的Extra Feature Layers通過pooling層(or stride=2)不斷将網格數減半,直至為1(4x4 -> 2x2 -> 1x1),相應的,每個網格的大小也随着網格數減半而翻倍增加。這樣一來,就可以創造出不同形狀大小的MultiBox(網格)來錨定不同形狀大小的物體。

了解了SSD的工作原理和網絡架構後,我們就要開始進入細節,逐漸學習SSD将會涉及的各個子產品。

Classification

延續上集的思路,我将多目标檢測也分解為分類(Classification)和定位(Location)兩個獨立操作。相比單目标檢測,Classification模型用sigmoid()而不是softmax()來生成分類的機率。為檢驗模型的準确率,這裡選取所有預測機率大于0.4的分類,可以看到,Classification模型是work的。

搞定目标檢測(SSD篇)(下)

Classification模型的意義在于,它可以幫助我們預估Object Detection模型的Classification準确率。

Ground Truth (Location)

Ground Truth指的是圖像的标注資訊,在本文中指的是bounding box和分類。

i = 9
bb = y[0][i].view(-1, 4)
clas = y[1][i]
bb, clas, bb.shape, clas.shape

(tensor([[  0.,   0.,   0.,   0.],
         [  0.,   0.,   0.,   0.],
         [  0.,   0.,   0.,   0.],
         [105.,   4., 161.,  28.],
         [ 70.,   0., 149.,  66.],
         [ 50.,  24., 185., 129.],
         [ 19.,  60., 223., 222.]], device='cuda:0'),
 tensor([ 0,  0,  0,  4, 14, 14, 14], device='cuda:0'),
 torch.Size([7, 4]),
 torch.Size([7]))
           

由于每個樣本的ground truth個數不同,為了保證mini-batch矩陣shape的一緻性,Pytorch會用0來填充y矩陣,是以,在使用y時,需要先剔除bounding box全為0的ground truth。

i = 9
fig, ax = plt.subplots(figsize=(6, 4))
ax.imshow(ima[i])
draw_gt(ax, y[0][i].view(-1, 4), y[1][i], num_classes=len(labels))
ax.axis('off')
           
搞定目标檢測(SSD篇)(下)

SSD Network Part 1

def conv_layer(nin, nf, stride=2, drop=0.1):
  return nn.Sequential(
      nn.Conv2d(nin, nf, 3, stride, 1, bias=False),
      nn.ReLU(),
      nn.BatchNorm2d(nf),
      nn.Dropout(drop)
  )

class Outlayer(nn.Module):
  def __init__(self, nf, num_classes, bias):
    super().__init__()
    self.clas_conv = nn.Conv2d(nf, num_classes + 1, 3, 1, 1)
    self.bb_conv = nn.Conv2d(nf, 4, 3, 1, 1)
    self.clas_conv.bias.data.zero_().add_(bias)
    
  def flatten(self, x):
    bs, nf, w, h = x.size()
    x = x.permute(0, 2, 3, 1).contiguous()
    return x.view(bs, -1, nf)
  
  def forward(self, x):
    return [self.flatten(self.bb_conv(x)), self.flatten(self.clas_conv(x))]

class SSDHead(nn.Module):
  def __init__(self, num_classes, nf, bias, drop_i=0.25):
    super().__init__()
    self.conv1 = conv_layer(512, nf, stride=1)
    self.conv2 = conv_layer(nf, nf)
    self.drop_i = nn.Dropout(drop_i)
    self.out = Outlayer(nf, num_classes, bias=bias)
  
  def forward(self, x):
    x = self.drop_i(F.relu(x))
    x = self.conv1(x)
    x = self.conv2(x)
    return self.out(x)
  
ssd_head_f = SSDHead(num_classes, nf, bias=-3.)
           

我使用的backbone是Resnet34,它最終的輸出結果是7x7x512,是以,經過stride=2的conv2層之後,将會得到如Figure 2所示的4x4 freature map。out層就是單目标檢測層,檢測結果儲存在axis 1(channels次元)上,out層會生成輸出:[[-1, 4, 4, 4],[-1, 21, 4, 4]],前者與bounding box相關,後者是分類機率。

之是以将clas_conv層的bias初始化為-3,是因為模型輸出的總loss值偏大。雖然可以通過訓練降低loss值,但模型卻達不到期望效果,是以,我采用bias指派的方法來解決這個問題。

為什麼是“與bounding box有關”,而不是bounding box?

搞定目标檢測(SSD篇)(上)已經提到,resnet這類模型是為圖像識别而生的,它們并不擅長解決空間問題,是以,SSD并不是直接預測bounding box位置,而是預測它們相對于靜态default box(Figure 2中的網格)的偏移(offset)。是以,bounding box的誤差不僅更小而且形狀大小也會更可控。

Default Box

Default Box就是“MultiBox”、取景框、Figure 2中的網格。在Faster R-CNN中它也被稱為archor box。它由[中心x、y坐标,width,height]組成。def_box就是在[0~1]範圍内的4x4的Default Box。

cells = 4
width = 1 / cells
cx = np.repeat(np.linspace(width / 2, 1 - (width / 2), cells), cells)
cy = np.tile(np.linspace(width / 2, 1 - (width / 2), cells), cells)
w = h = np.array([width] * cells**2)
def_box = T(np.stack([cx, cy, w, h], 1))
def_box

tensor([[0.1250, 0.1250, 0.2500, 0.2500],
        [0.1250, 0.3750, 0.2500, 0.2500],
        [0.1250, 0.6250, 0.2500, 0.2500],
        [0.1250, 0.8750, 0.2500, 0.2500],
        [0.3750, 0.1250, 0.2500, 0.2500],
        [0.3750, 0.3750, 0.2500, 0.2500],
        [0.3750, 0.6250, 0.2500, 0.2500],
        [0.3750, 0.8750, 0.2500, 0.2500],
        [0.6250, 0.1250, 0.2500, 0.2500],
        [0.6250, 0.3750, 0.2500, 0.2500],
        [0.6250, 0.6250, 0.2500, 0.2500],
        [0.6250, 0.8750, 0.2500, 0.2500],
        [0.8750, 0.1250, 0.2500, 0.2500],
        [0.8750, 0.3750, 0.2500, 0.2500],
        [0.8750, 0.6250, 0.2500, 0.2500],
        [0.8750, 0.8750, 0.2500, 0.2500]], device='cuda:0')
           
搞定目标檢測(SSD篇)(下)

Jaccard Index

你在Figure 2看到的網格被标記上各種分類,就是default box和ground truth互相比對後得到的結果。通過jaccard()計算每個default box和每個ground truth的交并比(overlap),通過篩選出overlap在axis 1的最大值,就可以知道每個ground truth應該比對哪個default box(gt_db_idx),而那些overlap > 0.5的default box則被認為是真正比對ground truth的box,通過db_gt_idx就可以知道每個default box對應的是ground truth還是background,進而建構基于default box的ground truth(db_clas)。

搞定目标檢測(SSD篇)(下)
def box_size(box): return (box[:, 2] - box[:, 0]) * (box[:, 3] - box[:, 1])

def intersection(gt, def_box):
  left_top = torch.max(gt[:, None, :2], def_box[None, :, :2])
  right_bottom = torch.min(gt[:, None, 2:], def_box[None, :, 2:])
  wh = torch.clamp(right_bottom - left_top, min=0)
  return wh[:, :, 0] * wh[:, :, 1]
  
def jaccard(gt, def_box):
  inter = intersection(gt, def_box)
  union = box_size(gt).unsqueeze(1) + box_size(def_box).unsqueeze(0) - inter
  return inter / union

overlap = jaccard(bb, def_box_bb * sz)
gt_best_overlap, gt_db_idx = overlap.max(1)
db_best_overlap, db_gt_idx = overlap.max(0)
db_best_overlap[gt_db_idx] = 1.1
is_obj = db_best_overlap > 0.5
pos_idxs = np.nonzero(is_obj)[:, 0]
neg_idxs = np.nonzero(1 - is_obj)[:, 0]
db_clas = T([num_classes] * len(db_best_overlap))
db_clas[pos_idxs] = clas[db_gt_idx[pos_idxs]]
db_best_overlap, db_clas
           

為什麼Figure 2很多default box被标記錯誤為background?

原因就在于4x4的default box小于ground truth,overlap很難達到0.5的thresh,降低thresh,添加更大的default box會得到更比對的效果。

More Default Boxes

還記得SSD的網絡架構麼,Extra Feature Layers中的feature map會随着pooling層從4x4->2x2->1x1,網格大小也會逐層翻倍,除此之外,SSD還會利用不同的寬縱比,為每一層生成大小相同但形狀不同的default box,換句話說,相比4x4,此時的模型可以更準确地比對更多類型的物體。

搞定目标檢測(SSD篇)(下)

如上圖所示,bounding box可以分為3大類:寬比高長、高比寬長、等長,是以我采用的寬縱比:[(1., 1.), (1., 0.5), (0.5, 1.)],并為每類都配置了scale系數:[0.7, 1., 1.3]。

cells = np.array([4, 2, 1])
center_offsets = 1 / cells / 2
aspect_ratios = [(1., 1.), (1., .5), (.5, 1.)]
zooms = [0.7, 1., 1.3]
scales = [(o * i, o * j) for o in zooms for i, j in aspect_ratios]
k = len(scales)
k, scales

(9,
 [(0.7, 0.7),
  (0.7, 0.35),
  (0.35, 0.7),
  (1.0, 1.0),
  (1.0, 0.5),
  (0.5, 1.0),
  (1.3, 1.3),
  (1.3, 0.65),
  (0.65, 1.3)])
           

k是每個default box根據寬縱比産生的變化數。如果把default box比作相機,k則是為這部相機配備的專業鏡頭數,不同拍攝場景使用不同的鏡頭。

搞定目标檢測(SSD篇)(下)

可以看到,Figure 3比Figure 2要精确很多,當然它的default box數也比之前要多很多: (4x4 + 2x2 + 1x1) * k。

Loss Function

SSD的損失函數和我們在上集介紹的方法類似,需要分别計算bounding box loss(Loc loss)和classification loss(Conf loss),并最終求和。另外 α \alpha α系數可以用來平衡兩種模型的優化比例,一般情況下,它被指派1。

搞定目标檢測(SSD篇)(下)

Loc loss是bounding box 和ground truth的L1 loss。Conf loss則是binary cross entropy。

class BCELoss(nn.Module):
  def __init__(self, num_classes):
    super().__init__()
    self.num_classes = num_classes
    
  def get_weight(self, x, t): return None
  
  def forward(self, x, t):
    x = x[:, :-1]
    one_hot_t = torch.eye(num_classes + 1)[t.data.cpu()]
    t = V(one_hot_t[:, :-1].contiguous())
    w = self.get_weight(x, t)
    return F.binary_cross_entropy_with_logits(x, t, w, size_average=False) / self.num_classes

bce_loss_f = BCELoss(num_classes)

def loc_loss(preds, targs):
  return (preds - targs).abs().mean()

def conf_loss(preds, targs):
  return bce_loss_f(preds, targs)
           

BCELoss中,之是以要去掉background分類的預測結果是因為db_clas包含了不屬于資料集的background分類,實際上,這就是為background分類變相生成全0的one-hot向量。之是以要将conf_loss的結果除以self.num_classes,是因為如果binary cross entropy采用sum而不非mean來處理loss,這樣一來,conf_loss就會偏大,反之如果采用mean來處理,conf_loss就會偏小。不管loss是偏大還是偏小,都不利于模型訓練,是以解決方法就是像bias初始化那樣主動降低loss值,至于除數(20),它是實際檢驗有效值。

def offset_to_bb(off, db_bb):
    off = F.tanh(off)
    center = (off[:, :2] / 2) * db_bb[:, 2:] + db_bb[:, :2]
    wh = ((off[:, 2:] / 2) + 1) * db_bb[:, 2:]
    return def_box_to_bb(center, wh)

def _ssd_loss(db_offset, clas, bb_gt, clas_gt):
  bb = offset_to_bb(db_offset, def_box)
  bb_gt = bb_gt.view(-1, 4) / sz
  idxs = np.nonzero(bb_gt[:, 2] > 0)[:, 0]
  bb_gt, clas_gt = bb_gt[idxs], clas_gt[idxs]
  overlap = jaccard(bb_gt, def_box_bb)
  gt_best_overlap, gt_db_idx = overlap.max(1)
  db_best_overlap, db_gt_idx = overlap.max(0)
  db_best_overlap[gt_db_idx] = 1.1
  for i, o in enumerate(gt_db_idx): db_gt_idx[o] = i
  is_obj = db_best_overlap >= 0.5
  pos_idxs = np.nonzero(is_obj)[:, 0]
  neg_idxs = np.nonzero(1 - is_obj.data)[:, 0]
  db_clas = clas_gt[db_gt_idx]
  db_clas[neg_idxs] = len(labels)
  db_bb = bb_gt[db_gt_idx]
  return (loc_loss(bb[pos_idxs], db_bb[pos_idxs]), bce_loss_f(clas, db_clas))

def ssd_loss(preds, targs, print_loss=False):
#   alpha = 1.
  loc_loss, conf_loss = 0., 0.
  for i, (db_offset, clas, bb_gt, clas_gt) in enumerate(zip(*preds, *targs)):
    losses = _ssd_loss(db_offset, clas, bb_gt, clas_gt)
    loc_loss += losses[0]# * alpha
    conf_loss += losses[1]
  if print_loss:
    print(f'loc loss: {loc_loss:.2f}, conf loss: {conf_loss:.2f}')
  return loc_loss + conf_loss
           

offset_to_bb()的作用就是根據default box offset來生成

bounding box。_ssd_loss()中很多代碼在前面已經講解過了,其目的就是根據jaccard index建構以default box為基礎的ground truth。

Train 4x4

終于來到模型訓練階段了,為了便于調試各子產品,先隻訓練4x4網格模型(SSD Network Part 1)。

lr = 1e-2
learn.fit(lr, 1, cycle_len=8, use_clr=(20, 5))
learn.save('16')

epoch      trn_loss   val_loss   
    0      33.574218  34.117771 
    1      30.093091  29.408577 
    2      27.206728  27.568285 
    3      25.348878  26.957813 
    4      23.976828  26.765239 
    5      22.80882   26.695604 
    6      21.532631  26.688388 
    7      20.018111  26.610572 
           
搞定目标檢測(SSD篇)(下)

從測試結果可以看到,bounding box都是基于default box生成的,模型預測結果也是準确的。

SSD Network Part 2

接下來,SSD會用更多default box來覆寫圖像。

class Outlayer(nn.Module):
  def __init__(self, nf, num_classes, bias):
    super().__init__()
    self.clas_conv = nn.Conv2d(nf, (num_classes + 1) * k, 3, 1, 1)
    self.bb_conv = nn.Conv2d(nf, 4 * k, 3, 1, 1)
    self.clas_conv.bias.data.zero_().add_(bias)
  
  def flatten(self, x):
    bs, nf, w, h = x.size()
    x = x.permute(0, 2, 3, 1).contiguous()
    return x.view(bs, -1, nf // k)
  
  def forward(self, x):
    return [self.flatten(self.bb_conv(x)), self.flatten(self.clas_conv(x))]

class SSDHead(nn.Module):
  def __init__(self, num_classes, nf, bias, drop_i=0.25, drop_h=0.1):
    super().__init__()
    self.conv1 = conv_layer(512, nf, stride=1, drop=drop_h)
    self.conv2 = conv_layer(nf, nf, drop=drop_h)   # 4x4
    self.conv3 = conv_layer(nf, nf, drop=drop_h)   # 2x2
    self.conv4 = conv_layer(nf, nf, drop=drop_h)   # 1x1
    self.drop_i = nn.Dropout(drop_i)
    self.out1 = Outlayer(nf, num_classes, bias)
    self.out2 = Outlayer(nf, num_classes, bias)
    self.out3 = Outlayer(nf, num_classes, bias)
  
  def forward(self, x):
    x = self.drop_i(F.relu(x))
    x = self.conv1(x)
    x = self.conv2(x)
    bb1, clas1 = self.out1(x)
    x = self.conv3(x)
    bb2, clas2 = self.out2(x)
    x = self.conv4(x)
    bb3, clas3 = self.out3(x)
    return [torch.cat([bb1, bb2, bb3], 1),
            torch.cat([clas1, clas2, clas3], 1)]

drops = [0.4, 0.2]
ssd_head_f = SSDHead(num_classes, nf, -4., drop_i=drops[0], drop_h=drops[1])
           

模型末層torch.cat()來彙總所有層的default box(4x4、2x2、1x1)檢測結果,因為每個default box會有k種變化,是以每個out層的輸出是原來的k倍。從之前的訓練來看,模型的正則化不足,是以我加大了dropout力度。

lr = 1e-2
learn.fit(lr, 1, cycle_len=10, use_clr=(20, 10))
learn.save('multi')

epoch      trn_loss   val_loss   
    0      87.026507  75.858966 
    1      68.657919  62.675859 
    2      58.815842  78.257847 
    3      53.675965  54.85459  
    4      49.656684  53.707109 
    5      46.777794  53.003534 
    6      44.20865   51.358076 
    7      41.394307  51.515281 
    8      38.741202  50.559135 
    9      36.69472   50.12559  
           
搞定目标檢測(SSD篇)(下)

可以看到,bounding box的定位比之前要更準确,這正是我所希望看到的,但随之而來的另一個問題是,為什麼酒瓶和離鏡頭較遠的兩個人卻沒有被定位?

這個問題留待後文來回答,我們先來解決備援bounding box的問題。對于那些作用于同一物體的衆多bounding box來說,我們隻需要overlap最高的那個即可,而這個工作是交給NMS層完成的。

NMS

SSD模型的最後一層是NMS:Non-Maximum Suppression,顧名思義,它的輸出不隻是overlap最大的結果,實際上,它常用于輸出overlap大于某個thresh的前N個結果。本例選出的是overlap > 0.4的前50個結果。

搞定目标檢測(SSD篇)(下)

世界一下子就清靜了,和預期一樣,4個目标隻有1個最明顯的目标被檢測出來了。

接下來該查找定位失敗的原因了。訓練模型時存在兩個問題:

  • 過拟合
  • 欠拟合,loss值較大

過拟合和欠拟合看似沖突,但如果檢查loc_loss和conf_loss,問題就說得通了。

x, y = next(iter(md.trn_dl))
yp = predict_batch(learn.model, x)
ssd_loss(yp, y, True)

loc loss: 3.65, conf loss: 28.08
tensor(31.7384, device='cuda:0', grad_fn=<AddBackward0>)
           

和預期一樣,conf_loss太大(28.08),classification欠拟合。雖然我之前說過目标檢測可以拆分為兩個獨立的操作:分類和定位,但實際上,location和classification出自同一個神經網絡,它們隻有最後一層是獨立的,其他層都是共享的,也就是說,如果模型classification準确率低,那location的準确率也高不到哪去,實際上,location是依賴于classification的,因為模型對離鏡頭比較遠的兩人以及酒瓶的classification準确率較低,是以location也就偏離這些物體,而模型對靠近鏡頭的那個人的classification準确率較高,是以,他的location就會較為準确。

我們需要更強的Loss Function。

Focal Loss / Paper

搞定目标檢測(SSD篇)(下)

從數學公式可以看出,focal loss是scale版的cross entropy, − ( 1 − p t ) γ -(1 - p_t)^\gamma −(1−pt​)γ是可訓練的scale值。在object dection中,focal loss的表現遠勝于BCE,其背後的邏輯是:通過scale(放大/縮小)參數,讓原本模糊不清的預測确定化。

Focal loss對well-classified examples降級,降低它們的loss值,也就是減少參數更新值,把更多優化空間留給預測機率較低的樣本。Focal loss是一種從整體上優化模型的算法。

當gamma == 0時,focal loss就相當于corss entropy(CE),如藍色曲線所示,即使probability達到0.6,loss值還是>= 0.5,就好像是說:“我判斷它不是分類B的機率是60%,恩,我還有繼續努力優化參數,我行的,其他事情不要來煩我,我要跟它死磕到底”。而當gamma == 2時,同樣是probability達到0.6,loss值卻接近于0,就好像是說:“我判斷它不是分類B的機率是60%,恩,根據我多年斷案經驗,它一定不是分類B,雖然判斷依據不是很高,但我宣布,結案了,這頁翻過去了,接下來我要把精力投入到那些預測準确率還很低的案子”。

class FocalLoss(BCELoss):
  def get_weight(self, x, t):
    alpha,gamma = 0.25,1
    p = x.sigmoid()
    pt = p*t + (1-p)*(1-t)
    w = alpha*t + (1-alpha)*(1-t)
    return w * (1-pt).pow(gamma)

bce_loss_f = FocalLoss(num_classes)
lr = 1e-2
learn.fit(lr, 1, cycle_len=10, use_clr=(20, 10))
learn.save('focal_loss')

epoch      trn_loss   val_loss   
    0      17.30767   18.866698 
    1      15.211579  13.772004 
    2      13.563804  13.015255 
    3      12.589626  12.785115 
    4      11.926406  12.28807  
    5      11.515744  11.814605 
    6      11.109133  11.686357 
    7      10.664063  11.424233 
    8      10.285392  11.338397 
    9      9.935587   11.185435 
           
搞定目标檢測(SSD篇)(下)

和預期一樣,雖然主體物體的分類準确率降低了(從0.77降低到0.5),但其他物體detector的預測準确率也提升了,所有人物的分類準确率都大于0.2,bounding box也都正常工作了。

酒瓶依舊無法被檢測,原因很可能是因為它比較小而且在邊緣位置,它所比對的bounding box也覆寫了旁邊的人物,根據receptive field的工作原理,酒瓶的classification準确率很低。解決方法:

  • 建構更豐富更适合酒瓶之類小物體的default_box。
  • default_box不變,用更多資料訓練更強的bounding box offset生成器。

END

SSD就像一個笨拙的攝影師兼後期制作達人,每次拍攝他都遵循同一套流程,取景、移動鏡頭到取景框中心位置、咔嚓一聲摁下快門,但他也是後期處理高手,可以根據衆多零碎畫面還原加工成目标畫面。

SSD的秘訣就在于事先需要準備好各種形狀大小的default box,這就好像是京東,送貨快的原因在于它在全國範圍内建好了大大小小各種物流倉位,隻要你不是在邊遠山區,大機率都能被附近的送貨站覆寫到,是以,送貨能不快麼。

當然,default box越多需要的算力就越大,這對于攝像頭這類的嵌入式裝置來說是個不小的算力挑戰:如果default box不豐富,定位的準确性就差,bounding box位置或大或小,體型小的物體也可能因為比對了錯誤的default box而分類準确率很低;如果增加default box,就會面臨算力跟不上的問題,如果你有興趣,可以增加aspect ratio或zoom來建立更多的default box,或者把7x7的網格也使用起來,你會發現在調用show_nmf()所花費的時間要較長。

It’s Really Over

Refences

  • SSD: Single Shot MultiBox Detector
  • Focal Loss for Dense Object Detection
  • 搞定目标檢測(SSD篇)(上)

繼續閱讀