基于航空影像深度学习的目标检测
1.数据集
对象检测部分,我们使用了由劳伦斯利弗莫尔国家实验室提供的Cars Overhead With Context (COWC)数据集(https://gdo152.llnl.gov/cowc/)。它具有在六个不同位置拍摄的航拍图像:
- 加拿大多伦多
- 塞尔温,新西兰
- 波茨坦和Vaihingen,德国
- 哥伦布(俄亥俄州)和美国犹他州
*我们最终没有使用哥伦布和Vaihingen的数据,因为图像是灰度的。
该数据集提供了大图像(高达4平方公里),分辨率高(每像素15厘米),并且每辆车都具有中心定位功能。我们假设汽车的平均尺寸为3米。我们创建了围绕每个汽车中心的箱子,以实现预测图像中箱子(即汽车)位置的最终目标。
图1:来自COWC数据集的示例图像
2.架构
为了检测这些大型航拍图像中的汽车,我们使用了RetinaNet架构(由Facebook FAIR于2017年发布,赢得了2017年ICCV最佳学生论文)。
对象检测体系结构分为两类:single-stage和two-stage。
two-stage体系结构首先将潜在对象分为两类:前景或背景。然后,将所有前景的潜在对象分类到更细的类别中:猫,狗,汽车等。这种two-stage方法非常缓慢,但会产生最佳准确性。最着名的two-stage架构是Faster-RCNN。
single-stage体系结构没有这个潜在前景对象的预选步骤。他们通常不太准确,但他们也更快。RetinaNet的single-stage体系结构是个例外:它具有single-stage速度和two-stage性能!
在下面的图2中,您可以看到各种对象检测体系结构的比较。
图2:对象检测算法的性能
RetinaNet由几个组件组成。我们将尝试描述数据如何通过每一步转换
图3:RetinaNet架构
2.1、卷积网络
首先是ResNet -50。就像每个卷积神经网络(CNN)一样,它将图像作为输入并通过多个卷积核进行处理。每个内核的输出都是一个特征映射 - 第一个特征映射捕获高级特征(例如线条或颜色)。我们越深入网络,特征映射就越小,因为有池化层。虽然它们较小,但它们也代表更多的细粒度信息(如眼睛,狗耳朵等)。输入图像有三个通道(红色,蓝色,绿色),但每个后续feature maps都有数十个通道!它们中的每一个代表它捕获的不同类型的特征。
一个普通的分类器使用ResNet的最后一个特征图(shape (7, 7, 2048)),在每个通道上应用一个平均池(resulting in(1, 1, 2048)),并将其馈送到一个具有softmax的全连接层。
2.2、特征金字塔网络(Feature Pyramid Network)
ResNet之后,RetinaNet添加了一个特征金字塔网络(FPN),而不是添加分类器。通过从ResNet的不同层选取feature maps,它提供了丰富的多尺度特征。
图4:backbone 和FPN之间的横向连接
然而,ResNet的第一张特征图可能太粗糙,无法提取任何有用的信息。正如您在图4中看到的那样,更小更精确的特征图与更大的特征图组合在一起。我们首先对较小的那个进行上取样,然后将它与大一些的进行相加。存在几种上采样方法; 这里,上采样是用最近邻方法完成的。
FPN的每个级别都以不同的比例编码不同类型的信息。因此,他们每个人都应该参与对象检测任务。FPN将ResNet 的第三个(512 channels),第四个(1024 channels)和第五个(2048 channels)块的输出作为输入。第三个是第四个的一半,第四个是第五个的一半。
我们应用点卷积(与1x1核的卷积)将每个层的通道数统一为256。然后我们对较小的能级进行向上采样,使之与较大能级的尺寸相匹配。
2.3. Anchors
在每个FPN级别,几个anchors 都围绕FPN的特征图移动。anchor 是一个具有不同大小和比例的矩形,如下所示:
图5:不同尺寸和比例的anchors的示例
这些anchors 是潜在物体的基础位置。存在五种尺寸和三种比率,因此有15种独特的anchors 。这些anchors 也根据FPN层面的尺寸进行缩放。这些独特的anchors 在特征图中的所有可能位置上都是重复的。它导致了总共有K个anchors 。
2.4、回归和分类
每个FPN’s level被馈送到两个完全卷积网络(FCN),它们是仅由卷积和池化而成的神经网络。为了充分利用每个FPN的等级拥有不同类型信息的事实,这两个FCN在各个层次之间共享!卷积层独立于输入大小; 只有它们的内核大小很重要 因此,虽然每个FPN的特征图具有不同的大小,但它们可以全部馈送到相同的FCN。
第一个FCN是回归分支。它预测K x 4 (每个anchor 的x1,y1,x2,y2)值。这些值是略微修改了的原始anchor ,以便更好地适应潜在对象。所有可能的对象现在都有这种类型的坐标:
(x1 + dx1, y1 + dy1, x2 + dx2, y2 + dy2)
这里x?和y?是anchor的固定坐标,dx?,dy?由回归分支所产生的变化量。
我们现在拥有所有对象的最终坐标 - 也就是所有可能的对象。他们还没有被归类为背景或汽车,卡车等。
第二个FCN是分类分支。这是一个多标签问题,分类器用sigmoid 预测潜在对象K x N(N 是类的数量)。
2.5、删除重复项
此时我们有K x 4坐标和K x N类得分。我们现在有一个问题:对同一个对象检测同一个对象的几个盒子是很常见的!
图6:单个汽车检测到几个boxes
因此,对于每个类(即使它不是最高得分类),我们都会应用非最大限制。Tensorflow提供了一个功能:
tf.image.non_max_suppression(boxes, scores, max_output_size, iou_threshold)
这种方法的主要要点是它将删除重叠的盒子(如图6),只保留一个。它也使用scores保留最可能的盒子。
上面的Tensorflow方法的输入参数的一般评论:max_output_size对应于我们最后想要的最大盒子数量 - 比方说300。iou_threshold是一个介于0和1之间的浮点数,描述了接受的最大重叠比例。
图7:应用非最大抑制后的图6
2.6、保持最可能的类
现在删除同一地点的同一类的复制框。对于剩下的每个箱子,我们只保留得分最高的类(汽车,卡车等)。如果没有一个分数的分数高于固定阈值(我们使用0.4),则认为它是背景的一部分。
2.7. The Focal Loss
所有这些听起来都很复杂,但这并不是什么新鲜事 - 仅仅具有良好的准确性是不够的。RetinaNet的真正改善是它的损失:Focal Loss。没有潜在对象预选的single-stage体系结构会被背景对象的高频率所淹没。Focal Loss是根据低分量来处理的,通常是背景。
图8:我们定义 Pt, the confidence to be right
在图8中,我们定义了Pt
图9:The Focal Loss
在图9中,我们将交叉熵损失-log(Pt)组成一个因子(1 — Pt)^y 。这里y是一个在0和5之间振荡的调制因子。良好分类的例子具有很高的Pt ,因此也是一个低因素。因此,良好分类的例子的损失非常低,迫使模型学习更难的例子。您可以在图10中看到损失有多大影响。
图10The focal loss under various modulating factors
3.实现
我们使Keras 实现 RetinaNet。我们还编写了一个新的generator,将Pandas的数据框替换为CSV文件。
class DfGenerator(CSVGenerator):
"""Custom generator intented to work with in-memory Pandas' dataframe."""
def __init__(self, df, class_mapping, cols, base_dir='', **kwargs):
"""Initialization method.
Arguments:
df: Pandas DataFrame containing paths, labels, and bounding boxes.
class_mapping: Dict mapping label_str to id_int.
cols: Dict Mapping 'col_{filename/label/x1/y1/x2/y2} to corresponding df col.
"""
self.base_dir = base_dir
self.cols = cols
self.classes = class_mapping
self.labels = {v: k for k, v in self.classes.items()}
self.image_data = self._read_data(df)
self.image_names = list(self.image_data.keys())
Generator.__init__(self, **kwargs)
def _read_classes(self, df):
return {row[0]: row[1] for _, row in df.iterrows()}
def __len__(self):
return len(self.image_names)
def _read_data(self, df):
data = {}
for _, row in df.iterrows():
img_file, class_name = row[self.cols['col_filename']], row[self.cols['col_label']]
x1, y1 = row[self.cols['col_x1']], row[self.cols['col_y1']]
x2, y2 = row[self.cols['col_x2']], row[self.cols['col_y2']]
if img_file not in data:
data[img_file] = []
# Image without annotations
if not isinstance(class_name, str) and np.isnan(class_name):
continue
data[img_file].append({
'x1': int(x1), 'x2': int(x2),
'y1': int(y1), 'y2': int(y2),
'class': class_name
})
return data
您可以看到,没有注释的图像保存在培训阶段。
我们在COCO上使用预先训练的RetinaNet ,然后对COWC数据集进行微调。这个新任务只有两个FCN被重新训练,而ResNet主干和FPN被冻结
您可以在下面的Python代码块中看到如何加载RetinaNet并编译它。请注意,skip_mismatch=True加载重量时添加非常重要!权重是在COCO上创建的,具有80个类别,但在我们的例子中,我们只有1个类别,因此Anchors的数量并不相同。
def load_retinanet(weights, n_classes, freeze=True):
modifier = freeze_model if freeze else None
model = resnet50_retinanet(num_classes=num_classes, modifier=modifier)
model.load_weights(weights, by_name=True, skip_mismatch=True)
return model
def compile(model):
model.compile(
loss={
'regression' : keras_retinanet.losses.smooth_l1(),
'classification': keras_retinanet.losses.focal()
},
optimizer=optimizers.adam(lr=configs['lr'], clipnorm=0.001)
)
def train(model, train_gen, val_gen, callbacks, n_epochs=20):
"""train_gen and val_gen are instances of DfGenerator."""
model.fit_generator(
train_gen,
steps_per_epoch=len(train_gen),
validation_data=val_gen,
validation_steps=len(val_gen),
callbacks=callbacks,
epochs=n_epochs,
verbose=2
)
我们仍然需要处理一些东西,来自COWC数据集的图像达4平方公里,或者13k像素宽和高。那些大图像达300MB。向RetinaNet提供如此大的图像是不切实际的。因此,我们将图像分割成1000x1000像素(或150x150米)的小块。
然而,错过汽车将是愚蠢的,因为它们被切割成两块。为了避免这个问题,我们制作了一个1000x1000像素的滑动窗口,它的移动速度是800像素。这样,两个相邻补丁之间有一个200像素宽的重叠。
这导致了另一个问题:我们可能会检测到两次汽车。要删除重复项,我们在将小块绑定在一起时应用了非最大抑制。事实上,这意味着我们有两次非最大抑制:在RetinaNet之后并且将小块捆绑在一起。对于第二个非最大抑制,我们使用了PyImageSearch的Numpy版本:
# import the necessary packages
import numpy as np
# Malisiewicz et al.
def non_max_suppression_fast(boxes, overlapThresh):
# if there are no boxes, return an empty list
if len(boxes) == 0:
return []
# if the bounding boxes integers, convert them to floats --
# this is important since we'll be doing a bunch of divisions
if boxes.dtype.kind == "i":
boxes = boxes.astype("float")
# initialize the list of picked indexes
pick = []
# grab the coordinates of the bounding boxes
x1 = boxes[:,0]
y1 = boxes[:,1]
x2 = boxes[:,2]
y2 = boxes[:,3]
# compute the area of the bounding boxes and sort the bounding
# boxes by the bottom-right y-coordinate of the bounding box
area = (x2 - x1 + 1) * (y2 - y1 + 1)
idxs = np.argsort(y2)
# keep looping while some indexes still remain in the indexes
# list
while len(idxs) > 0:
# grab the last index in the indexes list and add the
# index value to the list of picked indexes
last = len(idxs) - 1
i = idxs[last]
pick.append(i)
# find the largest (x, y) coordinates for the start of
# the bounding box and the smallest (x, y) coordinates
# for the end of the bounding box
xx1 = np.maximum(x1[i], x1[idxs[:last]])
yy1 = np.maximum(y1[i], y1[idxs[:last]])
xx2 = np.minimum(x2[i], x2[idxs[:last]])
yy2 = np.minimum(y2[i], y2[idxs[:last]])
# compute the width and height of the bounding box
w = np.maximum(0, xx2 - xx1 + 1)
h = np.maximum(0, yy2 - yy1 + 1)
# compute the ratio of overlap
overlap = (w * h) / area[idxs[:last]]
# delete all indexes from the index list that have
idxs = np.delete(idxs, np.concatenate(([last],
np.where(overlap > overlapThresh)[0])))
# return only the bounding boxes that were picked using the
# integer data type
return boxes[pick].astype("int")
在处理航拍图像时,我们可以使用大量的数据增强。首先,我们可以翻转水平轴和垂直轴。我们也可以以任何角度旋转图像。如果图像的比例不统一(无人机对地距离可能不恒定),则随机缩小和放大图像也很有用。
4.结果
您可以在下面的图11和图12中看到我们的RetinaNet在盐湖城这个图像上的表现。
图11:在盐湖城4平方公里的地区内检测到13,000辆汽车
图12:图11的放大
5、评估模型
精度是够不够?我们需要知道我们得到多少假阳性和假阴性。如果我们发现到处都是车,我们会有很多假阳性,但是如果我们错过了大部分的车,那就是很多假阴性。
召回衡量前者,而精度衡量后者。最后,f1-score是这两个指标的组合。
def compute_metrics(true_pos, false_pos, false_neg):
"""Compute the precision, recall, and f1 score."""
precision = true_pos / (true_pos + false_pos)
recall = true_pos / (true_pos + false_neg)
if precision == 0 or recall == 0:
return precision, recall, f1
f1 = 2 / (1 / precision + 1 / recall)
return precision, recall, f1
然而,我们并不期望RetinaNet检测汽车精确的像素。因此,我们正在计算被检测车辆和地面真实车辆的Jaccard指数,如果它超过了一个选择的阈值,我们认为汽车被正确地检测到了
def jaccard(box_a, box_b):
"""box_X is a tuple of the shape (x1, y1, x2, y2)."""
side1 = max(0, min(a[2], b[2]) - max(a[0], b[0]))
side2 = max(0, min(a[3], b[3]) - max(a[1], b[1]))
inter = side1 * side2
area_a = (a[2] - a[0]) * (a[3] - a[1])
area_b = (b[2] - b[0]) * (b[3] - b[1])
union = area_a + area_b - inter
return inter / union
def is_valid(box_pred, box_true, threshold=0.3):
return jaccard(box_red, box_true) >= threshold
图13:真阳性(绿色),假阳性(黄色)和假阴性(红色)
请注意,在四个误报中,其中两个是垃圾桶,一个是重复的,一个实际上是......一辆车!事实上,就像在每个数据集中一样,地面真实注释中可能存在一些错误。
在图12中,f1分数是0.91。通常在更多的城市环境中,f1分数大约为0.95。