本文主要解讀CenterNet如何加載數據,並將標注信息轉化為CenterNet規定的高斯分布的形式。
1. YOLOv3和CenterNet流程對比
CenterNet和Anchor-Based的方法不同,以YOLOv3為例,大致梳理一下模型的框架和數據處理流程。
YOLOv3是一個經典的單階段的目標檢測算法,圖片進入網絡的流程如下:
- 對圖片進行resize,長和寬都要是32的倍數。
- 圖片經過網絡的特征提取后,空間分辨率變為原來的1/32。
- 得到的Tensor去代表圖片不同尺度下的目標框,其中目標框的表示為(x,y,w,h,c),分別代表左上角坐標,寬和高,含有某物體的置信度。
- 訓練完成后,測試的時候需要使用非極大抑制算法得到最終的目標框。
CenterNet是一個經典的Anchor-Free目標檢測方法,圖片進入網絡流程如下:
- 對圖片進行resize,長和寬一般相等,並且至少為4的倍數。
- 圖片經過網絡的特征提取后,得到的特征圖的空間分辨率依然比較大,是原來的1/4。這是因為CenterNet采用的是類似人體姿態估計中用到的骨干網絡,基於heatmap提取關鍵點的方法需要最終的空間分辨率比較大。
- 訓練的過程中,CenterNet得到的是一個heatmap,所以標簽加載的時候,需要轉為類似的heatmap熱圖。
- 測試的過程中,由於只需要從熱圖中提取目標,這樣就不需要使用NMS,降低了計算量。
2. CenterNet部分詳解
設輸入圖片為\(I\in R^{W\times H\times 3}\), W代表圖片的寬,H代表高。CenterNet的輸出是一個關鍵點熱圖heatmap。
其中R代表輸出的stride大小,C代表關鍵點的類型的個數。
舉個例子,在COCO數據集目標檢測中,R設置為4,C的值為80,代表80個類別。
如果\(\hat{Y}_{x,y,c}=1\)代表檢測到一個物體,表示對類別c來說,(x,y)這個位置檢測到了c類的目標。
既然輸出是熱圖,標簽構建的ground truth也必須是熱圖的形式。標注的內容一般包含(x1,y1,x2,y2,c),目標框左上角坐標、右下角坐標和類別c,按照以下流程轉為ground truth:
- 得到原圖中對應的中心坐標\(p=(\frac{x1+x2}{2}, \frac{y1+y2}{2})\)
- 得到下采樣后的feature map中對應的中心坐標\(\tilde{p}=\lfloor \frac{p}{R}\rfloor\), R代表下采樣倍數,CenterNet中R為4
- 如果輸入圖片為512,那么輸出的feature map的空間分辨率為[128x128], 將標注的目標框以高斯核的方式將關鍵點分布到特征圖上:
其中\(\sigma_p\)是一個與目標大小相關的標准差(代碼中設置的是)。對於特殊情況,相同類別的兩個高斯分布發生了重疊,重疊元素間最大的值作為最終元素。下圖是知乎用戶OLDPAN分享的高斯分布圖。
3. 代碼部分
datasets/pascal.py 的代碼主要從getitem函數入手,以下代碼已經做了注釋,其中最重要的兩個部分一個是如何獲取高斯半徑(gaussian_radius函數),一個是如何將高斯分布分散到heatmap上(draw_umich_gaussian函數)。
def __getitem__(self, index):
img_id = self.images[index]
img_path = os.path.join(
self.img_dir, self.coco.loadImgs(ids=[img_id])[0]['file_name'])
ann_ids = self.coco.getAnnIds(imgIds=[img_id])
annotations = self.coco.loadAnns(ids=ann_ids)
labels = np.array([self.cat_ids[anno['category_id']]
for anno in annotations])
bboxes = np.array([anno['bbox']
for anno in annotations], dtype=np.float32)
if len(bboxes) == 0:
bboxes = np.array([[0., 0., 0., 0.]], dtype=np.float32)
labels = np.array([[0]])
bboxes[:, 2:] += bboxes[:, :2] # xywh to xyxy
img = cv2.imread(img_path)
height, width = img.shape[0], img.shape[1]
# 獲取中心坐標p
center = np.array([width / 2., height / 2.],
dtype=np.float32) # center of image
scale = max(height, width) * 1.0 # 仿射變換
flipped = False
if self.split == 'train':
# 隨機選擇一個尺寸來訓練
scale = scale * np.random.choice(self.rand_scales)
w_border = get_border(128, width)
h_border = get_border(128, height)
center[0] = np.random.randint(low=w_border, high=width - w_border)
center[1] = np.random.randint(low=h_border, high=height - h_border)
if np.random.random() < 0.5:
flipped = True
img = img[:, ::-1, :]
center[0] = width - center[0] - 1
# 仿射變換
trans_img = get_affine_transform(
center, scale, 0, [self.img_size['w'], self.img_size['h']])
img = cv2.warpAffine(
img, trans_img, (self.img_size['w'], self.img_size['h']))
# 歸一化
img = (img.astype(np.float32) / 255.)
if self.split == 'train':
# 對圖片的亮度對比度等屬性進行修改
color_aug(self.data_rng, img, self.eig_val, self.eig_vec)
img -= self.mean
img /= self.std
img = img.transpose(2, 0, 1) # from [H, W, C] to [C, H, W]
# 對Ground Truth heatmap進行仿射變換
trans_fmap = get_affine_transform(
center, scale, 0, [self.fmap_size['w'], self.fmap_size['h']]) # 這時候已經是下采樣為原來的四分之一了
# 3個最重要的變量
hmap = np.zeros(
(self.num_classes, self.fmap_size['h'], self.fmap_size['w']), dtype=np.float32) # heatmap
w_h_ = np.zeros((self.max_objs, 2), dtype=np.float32) # width and height
regs = np.zeros((self.max_objs, 2), dtype=np.float32) # regression
# indexs
inds = np.zeros((self.max_objs,), dtype=np.int64)
# 具體選擇哪些index
ind_masks = np.zeros((self.max_objs,), dtype=np.uint8)
for k, (bbox, label) in enumerate(zip(bboxes, labels)):
if flipped:
bbox[[0, 2]] = width - bbox[[2, 0]] - 1
# 對檢測框也進行仿射變換
bbox[:2] = affine_transform(bbox[:2], trans_fmap)
bbox[2:] = affine_transform(bbox[2:], trans_fmap)
# 防止越界
bbox[[0, 2]] = np.clip(bbox[[0, 2]], 0, self.fmap_size['w'] - 1)
bbox[[1, 3]] = np.clip(bbox[[1, 3]], 0, self.fmap_size['h'] - 1)
# 得到高和寬
h, w = bbox[3] - bbox[1], bbox[2] - bbox[0]
if h > 0 and w > 0:
obj_c = np.array([(bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2],
dtype=np.float32) # 中心坐標-浮點型
obj_c_int = obj_c.astype(np.int32) # 整型的中心坐標
# 根據一元二次方程計算出最小的半徑
radius = max(0, int(gaussian_radius((math.ceil(h), math.ceil(w)), self.gaussian_iou)))
# 得到高斯分布
draw_umich_gaussian(hmap[label], obj_c_int, radius)
w_h_[k] = 1. * w, 1. * h
# 記錄偏移量
regs[k] = obj_c - obj_c_int # discretization error
# 當前是obj序列中的第k個 = fmap_w * cy + cx = fmap中的序列數
inds[k] = obj_c_int[1] * self.fmap_size['w'] + obj_c_int[0]
# 進行mask標記
ind_masks[k] = 1
return {'image': img, 'hmap': hmap, 'w_h_': w_h_, 'regs': regs,
'inds': inds, 'ind_masks': ind_masks, 'c': center,
's': scale, 'img_id': img_id}
4. heatmap上應用高斯核
heatmap上使用高斯核有很多需要注意的細節。CenterNet官方版本實際上是在CornerNet的基礎上改動得到的,有很多祖傳代碼。
在使用高斯核前要考慮這樣一個問題,下圖來自於CornerNet論文中的圖示,紅色的是標注框,但綠色的其實也可以作為最終的檢測結果保留下來。那么這個問題可以轉化為綠框在紅框多大范圍以內可以被接受。使用IOU來衡量紅框和綠框的貼合程度,當兩者IOU>0.7的時候,認為綠框也可以被接受,反之則不被接受。
那么現在問題轉化為,如何確定半徑r, 讓紅框和綠框的IOU大於0.7。
以上是三種情況,其中藍框代表標注框,橙色代表可能滿足要求的框。這個問題最終變為了一個一元二次方程有解的問題,同時由於半徑必須為正數,所以r的取值就可以通過求根公式獲得。
def gaussian_radius(det_size, min_overlap=0.7):
# gt框的長和寬
height, width = det_size
a1 = 1
b1 = (height + width)
c1 = width * height * (1 - min_overlap) / (1 + min_overlap)
sq1 = np.sqrt(b1 ** 2 - 4 * a1 * c1)
r1 = (b1 + sq1) / (2 * a1)
a2 = 4
b2 = 2 * (height + width)
c2 = (1 - min_overlap) * width * height
sq2 = np.sqrt(b2 ** 2 - 4 * a2 * c2)
r2 = (b2 + sq2) / (2 * a2)
a3 = 4 * min_overlap
b3 = -2 * min_overlap * (height + width)
c3 = (min_overlap - 1) * width * height
sq3 = np.sqrt(b3 ** 2 - 4 * a3 * c3)
r3 = (b3 + sq3) / (2 * a3)
return min(r1, r2, r3)
可以看到這里的公式和上圖計算的結果是一致的,需要說明的是,CornerNet最開始版本中這里出現了錯誤,分母不是2a,而是直接設置為2。CenterNet也延續了這個bug,CenterNet作者回應說這個bug對結果的影響不大,但是根據issue的討論來看,有一些人通過修正這個bug以后,可以讓AR提升1-3個百分點。以下是有bug的版本,CornerNet最新版中已經修復了這個bug。
def gaussian_radius(det_size, min_overlap=0.7):
height, width = det_size
a1 = 1
b1 = (height + width)
c1 = width * height * (1 - min_overlap) / (1 + min_overlap)
sq1 = np.sqrt(b1 ** 2 - 4 * a1 * c1)
r1 = (b1 + sq1) / 2
a2 = 4
b2 = 2 * (height + width)
c2 = (1 - min_overlap) * width * height
sq2 = np.sqrt(b2 ** 2 - 4 * a2 * c2)
r2 = (b2 + sq2) / 2
a3 = 4 * min_overlap
b3 = -2 * min_overlap * (height + width)
c3 = (min_overlap - 1) * width * height
sq3 = np.sqrt(b3 ** 2 - 4 * a3 * c3)
r3 = (b3 + sq3) / 2
return min(r1, r2, r3)
同時有一些人認為圓並不普適,提出了使用橢圓來進行計算,也有人在issue中給出了推導,感興趣的可以看以下鏈接:https://github.com/princeton-vl/CornerNet/issues/110
5. 高斯分布添加到heatmap上
def gaussian2D(shape, sigma=1):
m, n = [(ss - 1.) / 2. for ss in shape]
y, x = np.ogrid[-m:m + 1, -n:n + 1]
h = np.exp(-(x * x + y * y) / (2 * sigma * sigma))
h[h < np.finfo(h.dtype).eps * h.max()] = 0
# 限制最小的值
return h
def draw_umich_gaussian(heatmap, center, radius, k=1):
# 得到直徑
diameter = 2 * radius + 1
gaussian = gaussian2D((diameter, diameter), sigma=diameter / 6)
# sigma是一個與直徑相關的參數
# 一個圓對應內切正方形的高斯分布
x, y = int(center[0]), int(center[1])
height, width = heatmap.shape[0:2]
# 對邊界進行約束,防止越界
left, right = min(x, radius), min(width - x, radius + 1)
top, bottom = min(y, radius), min(height - y, radius + 1)
# 選擇對應區域
masked_heatmap = heatmap[y - top:y + bottom, x - left:x + right]
# 將高斯分布結果約束在邊界內
masked_gaussian = gaussian[radius - top:radius + bottom,
radius - left:radius + right]
if min(masked_gaussian.shape) > 0 and min(masked_heatmap.shape) > 0: # TODO debug
np.maximum(masked_heatmap, masked_gaussian * k, out=masked_heatmap)
# 將高斯分布覆蓋到heatmap上,相當於不斷的在heatmap基礎上添加關鍵點的高斯,
# 即同一種類型的框會在一個heatmap某一個類別通道上面上面不斷添加。
# 最終通過函數總體的for循環,相當於不斷將目標畫到heatmap
return heatmap
使用matplotlib對gaussian2D進行可視化。
import numpy as np
y,x = np.ogrid[-4:5,-3:4]
sigma = 1
h=np.exp(-(x*x+y*y)/(2*sigma*sigma))
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
fig = plt.figure()
ax = Axes3D(fig)
ax.plot_surface(x,y,h)
plt.show()
6. 參考
[1]https://zhuanlan.zhihu.com/p/66048276
[2]https://www.cnblogs.com/shine-lee/p/9671253.html
[3]https://zhuanlan.zhihu.com/p/96856635