保姆级 Keras 实现 Faster R-CNN 三
上一篇文章 中我们讲了如何生成 anchor box, 但是只是生成 anchor box 并不能直接开始训练, 因为还缺少标签, 所以本篇重点就放在如何为这些生成的 anchor box 打标签
一. 从 xml 或者 json 文件中读出标注信息
VOC 数据集 中的标签文件是 xml 格式, 上一篇文章 中大概看了一下, 而 json 格式的标注在 语义分割之 json 文件分析 中也分析了一下, 所以下面就直接上代码
# 从 xml 或 json 文件中读出 ground_truth
# data_set: get_data_set 函数返回的列表
# categories: 类别列表
# file_type: 标注文件类型
# 返回 ground_truth 坐标与类别
def get_ground_truth(label_path, file_type, categories):
ground_truth = []
with open(label_path, 'r', encoding = "utf-8") as f:
if "json" == file_type:
jsn = f.read()
js_dict = json.loads(jsn)
shapes = js_dict["shapes"] # 取出所有图形
for shape in shapes:
if shape["label"] in categories:
pts = shape["points"]
x1 = round(pts[0][0])
x2 = round(pts[1][0])
y1 = round(pts[0][1])
y2 = round(pts[1][1])
# 防止有些人标注的时候喜欢从右下角拉到左上角
if x1 > x2:
x1, x2 = x2, x1
if y1 > y2:
y1, y2 = y2, y1
bnd_box = [x1, y1, x2, y2]
cls_id = categories.index(shape["label"])
# 把 bnd_box 和 cls_id 组合在一起, 后面可有会用得上
ground_truth.append([bnd_box, cls_id])
elif "xml" == file_type:
tree = et.parse(f)
root = tree.getroot()
for obj in root.iter("object"):
cls_id = obj.find("name").text
cls_id = categories.index(cls_id) # 类别 id
bnd_box = obj.find("bndbox")
bnd_box = [int(bnd_box.find("xmin").text),
int(bnd_box.find("ymin").text),
int(bnd_box.find("xmax").text),
int(bnd_box.find("ymax").text)]
# 把 bnd_box 和 cls_id 组合在一起, 后面可有会用得上
ground_truth.append([bnd_box, cls_id])
return ground_truth
测试 get_ground_truth 函数
# 测试 get_ground_truth
label_data = train_set[idx] # idx 上面已经定义过了
gts = get_ground_truth(label_data[1], label_data[2], CATEGORIES)
img_copy = image.copy()
# 图像缩放之后, ground_truth 也要做相应的缩放, scale 由 new_size_image 返回
for gt in gts:
gt[0][0] = round(gt[0][0] * scale)
gt[0][1] = round(gt[0][1] * scale)
gt[0][2] = round(gt[0][2] * scale)
gt[0][3] = round(gt[0][3] * scale)
print(gt, "class:", CATEGORIES[gt[1]])
cv.rectangle(img_copy, (gt[0][0], gt[0][1]), (gt[0][2], gt[0][3]),
(0, random.randint(128, 256), 0), 2)
plt.figure("label_box", figsize = (8, 4))
plt.imshow(img_copy[..., : : -1]) # 这里的通道要反过来显示才正常
plt.show()
[[25, 40, 82, 101], 1] class: aeroplane
[[42, 136, 100, 190], 1] class: aeroplane
[[58, 215, 114, 269], 1] class: aeroplane
[[170, 128, 229, 184], 1] class: aeroplane
[[147, 26, 205, 86], 1] class: aeroplane
[[357, 13, 415, 75], 1] class: aeroplane
嗯. 证明了代码还是没有问题的~~ 只是要注意 图像缩放之后, ground_truth 也要做相应的缩放
这里要说明一点, 在 VOC 数据集中有一些图是漏标注的, 所以当你测试的图像没有标注框的时候, 不一定是代码错了, 你要检查一下是不是真的漏标注了
二. 为 anchor box 打标签
1. 如何区分正负样本与中立样本
既然我们这么能干, 已经可以读出标签信息了, 那接下来肯定要为每一个 anchor box 打类别标签了. 这里的类别其实只有两类, 一类是背景, 一类是目标. 还没有到真正细分类别的时候, 只是替代 Selective Search 作为候选区域. 因为我们把激活函数替换成了 sigmoid, 所以标签就只是 0 或 1 了. 又因为我们把 RPN 网络中的回归部分也去掉了, 所以暂时就不用为回归打标签. 不用担心, 到时讲回归的时候会补回来的
标签为正样本还是负样本在 上一篇文章 中已经讲过规则了, 这里再重复一下
- 与任意的 ground truth IoU ≥ 0.7 是目标, IoU ≤ 0.3 是背景, 0.3 < IoU < 0.7 这部分不用管, 不参加训练
- 如果其中一个 ground truth 没有任何一个 anchor box 与之 IoU ≥ 0.7, 那与之 IoU 最大的那个 anchor box 也算目标, 也就是正样本
问: 正负样本的阈值分别是 0.3 和 0.7, 那 0.3 < IoU < 0.7 的样本怎么办, 又如何区分?
IoU 介于 0.3 到 0.7 的样本我们姑且叫作 中立 样本, 这样的样本是不参加训练的. 说不参加训练是假的, 因为都在一个网络里面走, 而真正的意思在计算 Loss 的时候不考虑它, 忽略它
至于如何区分中立样本, 数字 1 作为正样本, 0 作为负样本是比较符合我们习惯的, 那我们就把 -1 作为中立样本, 在计算 Loss 的时候只计算标签为 0 或者 1 的样本
2. 代码实现
用代码怎么实现上面的两条规则呢? 我们先用一个表来理解
a \ g | g 1 g_1 g1 | g 2 g_2 g2 | g 3 g_3 g3 | g 4 g_4 g4 | … |
---|---|---|---|---|---|
a 1 a_1 a1 | 0.1 | 0.8 | 0.3 | 0.9 | |
a 2 a_2 a2 | 0.1 | 0.8 | 0.1 | 0.5 | |
a 3 a_3 a3 | 0.6 | 0.0 | 0.3 | 0.4 | |
a 4 a_4 a4 | 0.1 | 0.2 | 0.8 | 0.4 | |
a 5 a_5 a5 | 0.2 | 0.2 | 0.0 | 0.1 | |
… |
表格中 a i a_i ai 和 g i g_i gi 分别表示 anchor box 和 ground truth
先看行数据, 第 1 行中 Iou( a 1 a_1 a1, g 4 g_4 g4) = 0.9, 所以 a 1 a_1 a1 是正样本, 同时 IoU( a 1 a_1 a1, g 2 g_2 g2) = 0.8, 虽然也大于了正样本阈值 0.7, 但是 a 1 a_1 a1 所对应的 ground truth 应该 g 4 g_4 g4, 算回归偏移的时候要以 g 4 g_4 g4 为准. 第 2 行 IoU( a 2 a_2 a2, g 2 g_2 g2) = 0.8, 所以 a 2 a_2 a2 也是正样本. 第 3 行 0.3 < Iou( a 3 a_3 a3, g 1 g_1 g1) < 0.6, 暂时算中立样本. 同理, 第 4 行 a 4 a_4 a4 也是正样本, 第 5 行 max IoU < 0.3, 所以 a 5 a_5 a5 是负样本
为什么说 a 3 a_3 a3 暂时算中立样本呢? 因为 g 1 g_1 g1 没有一个 anchor box 和它的 IoU ≥ 0.7, 也就是说 g 1 g_1 g1 没有与之对应的正样本, 所以我们这时要从列方向来看数据. g 1 g_1 g1 所在列与之 IoU 最大的是 a 3 a_3 a3, 按照规则, 要将 a 3 a_3 a3 算作正样本
到这里是否终于明白了那两条规则了, 那就可以上代码了
# 为每一个 anchor box 打类别标签
# anchors: create_train_anchors 生成的 anchor_box
# train_num: 每一张图中参加训练的样本数量
# 返回每一个 anchor box 的标签类型 1: 正, 0: 负: -1: 中立
POS_VAL = 1 # 正样本
NEG_VAL = 0 # 负样本
NEUTRAL = -1 # 其他不参与计算 loss 的样本
def get_rpn_cls_label(img_shape, anchors, ground_truth,
pos_thres = 0.7, neg_thres = 0.3, train_num = TRAIN_NUM):
cls_labels = [] # 存放每个 anchor_box 的标签值和对应的 gt 坐标
iou_matrix = [] # 暂时用来存放每个 anchor_box 与 每个 gt 的 iou, 后面用来判断是正样本还是负样本
# anchor_box 为列, ground_truth 为行, 组合成一个二维列表
# 交点就是 第 i 个 anchor_box 与 第 j 个 gt 的 iou
# 这样做的目录是方便为 anchor_box 分配一个与之 iou 最大的 ground_truth box
for a in anchors:
row_iou = [] # 行, 一个 anchor_box 与 所有 gt 的 iou
for gt in ground_truth:
'''
# 截断代码-------------------------
a[0] = max(a[0], 0)
a[1] = max(a[1], 0)
a[2] = min(a[2], img_shape[1] - 1)
a[3] = min(a[3], img_shape[0] - 1)
iou = get_iou(a, gt[0]) # gt[0] 表示 ground_true box, gt[1] 表示类别
# 截断代码-------------------------
'''
# 舍去代码-------------------------
iou = get_iou(a, gt[0]) # gt[0] 表示 ground_true box, gt[1] 表示类别
if a[0] < 0 or a[1] < 0 or a[2] >= img_shape[1] or a[3] >= img_shape[0]:
iou = -1.0 # 没有赋值为 0, 是因为这样的样本也不参加训练
# 舍去代码-------------------------
row_iou.append(iou)
iou_matrix.append(row_iou)
for r in iou_matrix:
# 每一行的最大 iou, 意思就是: 计算某一个 anchor_box 与 每个 gt 的 iou 后取最大值
max_iou = max(r)
# 如果与其中一个 gt 的 iou >= pos_thres, 则为正样本
if (max_iou >= pos_thres):
gt = ground_truth[r.index(max_iou)][0] # 这样做不担心有两个一样大的 IoU 吗? 你自己想一下
cls_labels.append((POS_VAL, gt)) # 把 gt 加进去方便后面的回归标签生成
elif (0 <= max_iou <= neg_thres): # 这里要判断 0 <= max_iou 是排除超边界的框
# 在 batch_size > 1 训练时会做 padding, 所以不排除的话
# padding 的黑边就会参与训练
cls_labels.append((NEG_VAL, [0, 0, 0, 0])) # 负样本的 gt 直接赋值为 0, 保持数据的格式一致
else:
cls_labels.append((NEUTRAL, [0, 0, 0, 0]))
# 如果某一个 gt 没有一个 anchor box 与它的 iou >= pos_thres
# 那就要找出与之最大 iou 的那个 anchor box 为正样本
# 但是这个 iou 还是要大于 0
for g in range(len(ground_truth)):
max_iou = 0;
for a in range(len(anchors)):
# a 行 g 列
if (iou_matrix[a][g] > max_iou):
max_iou = iou_matrix[a][g]
if 0 < max_iou < pos_thres:
# 当 anchor_box 与 gt box IoU < pos_thres 时
# 一个 gt box 可能有多个与之 IoU 一样大的 anchor_box, 比如 anchor_box 在 gt 的内部
# 所有这些 anchor_box 与 gt IoU 一样大的都要设置成正样本
for a in range(len(anchors)):
if iou_matrix[a][g] >= max_iou:
cls_labels[a] = (POS_VAL, ground_truth[g][0])
# 取出所有正样本与负样本的序号, 方便计数与打乱处理
positives = [i for i, x in enumerate(cls_labels) if POS_VAL == x[0]]
negatives = [i for i, x in enumerate(cls_labels) if NEG_VAL == x[0]]
shuffle(positives) # 打乱
shuffle(negatives)
# 如果正样本数量超过 train_num // 2, 随机选 train_num // 2 个,
# 上面打乱后直接取前 train_num // 2 个
pos_num = min(train_num // 2, len(positives))
for p in positives[pos_num: ]:
cls_labels[p] = (NEUTRAL, [0, 0, 0, 0]) # 去掉多余的正样本
# 参加训练的负样本的数量
train_negs = train_num - pos_num
for n in negatives[train_negs: ]:
cls_labels[n] = (NEUTRAL, [0, 0, 0, 0]) # 去掉多余的负样本
cls_ids = [] # 每个 anchor 标签, POS_VAL, NEG_VAL 或 NEUTRAL
gt_boxes = [] # 每个 anchor 对应的 gt 坐标
for label in cls_labels:
cls_ids.append(label[0])
gt_boxes.append(label[1])
return cls_ids, gt_boxes
上面注释的截断代码和舍去代码, 你选一种你喜欢的方式就可以了, 接下来测试
# 测试 get_rpn_cls_label, 将其画到图像上, 这里 train_num 设置为 32, 方便显示
rpn_cls_label, gt_boxes = get_rpn_cls_label(image.shape, anchors, gts, train_num = 32)
print("positive boxes: ", rpn_cls_label.count(POS_VAL))
print("negative boxes: ", rpn_cls_label.count(NEG_VAL))
img_copy = image.copy()
for i, a in enumerate(anchors):
if POS_VAL == rpn_cls_label[i]:
gt = gt_boxes[i]
# 测试 get_rpn_cls_label 带出来的 gt 是否正确
cv.rectangle(img_copy, (gt[0], gt[1]), (gt[2], gt[3]), (255, 55, 55), 2)
cv.rectangle(img_copy, (a[0], a[1]), (a[2], a[3]), (0, 255, 0), 2)
elif NEG_VAL == rpn_cls_label[i]:
cv.rectangle(img_copy, (a[0], a[1]), (a[2], a[3]), (0, 0, random.randint(128, 256)), 1)
plt.figure("anchor_box", figsize = (8, 4))
plt.imshow(img_copy[..., : : -1]) # 这里的通道要反过来显示才正常
plt.show()
positive boxes: 7
negative boxes: 25
测试图中, 红框为负样本, 绿框为正样本, 蓝色框是对应的 ground truth. 到这里标签的生成就算完成了
三. 代码下载
示例代码可下载 Jupyter Notebook 示例代码
上一篇: 保姆级 Keras 实现 Faster R-CNN 二
下一篇: 保姆级 Keras 实现 Faster R-CNN 四