
在前面的文章中我们看到了如何使用 CNN 模型识别图片里面的物体是什么类型或者识别图片中固定的文字 (即验证码)因为模型会把整个图片当作输入并输出固定的结果所以图片中只能有一个主要的物体或者固定数量的文字。如果图片包含了多个物体我们想识别有哪些物体各个物体在什么位置那么只用 CNN 模型是无法实现的。我们需要可以找出图片哪些区域包含物体并且判断每个区域包含什么物体的模型这样的模型称为对象识别模型 (Object Detection Model)最早期的对象识别模型是 RCNN 模型后来又发展出 Fast-RCNN (SPPnet)Faster-RCNN 和 YOLO 等模型。因为对象识别需要处理的数据量多速度会比较慢 (例如 RCNN 检测单张图片包含的物体可能需要几十秒)而对象识别通常又要求实时性 (例如来源是摄像头提供的视频)所以如何提升对象识别的速度是一个主要的命题后面发展出的 Faster-RCNN 与 YOLO 都可以在一秒钟检测几十张图片。对象识别的应用范围比较广例如人脸识别车牌识别自动驾驶等等都用到了对象识别的技术。对象识别是当今机器学习领域的一个前沿2017 年研发出来的 Mask-RCNN 模型还可以检测对象的轮廓。因为看上去越神奇的东西实现起来越难对象识别模型相对于之前介绍的模型难度会高很多请做好心理准备。对象识别模型需要的训练数据在介绍具体的模型之前我们首先看看对象识别模型需要什么样的训练数据对象识别模型需要给每个图片标记有哪些区域与每个区域对应的标签也就是训练数据需要是列表形式的。区域的格式通常有两种(x, y, w, h) 左上角的坐标与长宽与 (x1, y1, x2, y2) 左上角与右下角的坐标这两种格式可以互相转换处理的时候只需要注意是哪种格式即可。标签除了需要识别的各个分类之外还需要有一个特殊的非对象 (背景) 标签表示这个区域不包含任何可以识别的对象因为非对象区域通常可以自动生成所以训练数据不需要包含非对象区域与标签。RCNNRCNN (Region Based Convolutional Neural Network) 是最早期的对象识别模型实现比较简单可以分为以下步骤用某种算法在图片中选取 2000 个可能出现对象的区域截取这 2000 个区域到 2000 个子图片然后缩放它们到一个固定的大小用普通的 CNN 模型分别识别这 2000 个子图片得出它们的分类排除标记为 非对象 分类的区域把剩余的区域作为输出结果你可能已经从步骤里看出RCNN 有几个大问题结果的精度很大程度取决于选取区域使用的算法选取区域使用的算法是固定的不参与学习如果算法没有选出某个包含对象区域那么怎么学习都无法识别这个区域出来慢贼慢识别 1 张图片实际等于识别 2000 张图片后面介绍模型结果会解决这些问题但首先我们需要理解最简单的 RCNN 模型接下来我们细看一下 RCNN 实现中几个重要的部分吧。选取可能出现对象的区域选取可能出现对象的区域的算法有很多种例如滑动窗口法 (Sliding Window) 和选择性搜索法 (Selective Search)。滑动窗口法非常简单决定一个固定大小的区域然后按一定距离滑动得出下一个区域即可。滑动窗口法实现简单但选取出来的区域数量非常庞大并且精度很低所以通常不会使用这种方法除非物体大小固定并且出现的位置有一定规律。选择性搜索法则比较高级以下是简单的说明摘自 opencv 的文章你还可以参考 这篇文章 或 原始论文 了解具体的计算方法。如果你觉得难以理解可以跳过因为接下来我们会直接使用 opencv 类库中提供的选择搜索函数。而且选择搜索法精度也不高后面介绍的模型将会使用更好的方法。# 使用 opencv 类库中提供的选择搜索函数的代码例子 import cv2 img cv2.imread(图片路径) s cv2.ximgproc.segmentation.createSelectiveSearchSegmentation() s.setBaseImage(img) s.switchToSelectiveSearchFast() boxes s.process() # 可能出现对象的所有区域会按可能性排序 candidate_boxes boxes[:2000] # 选取头 2000 个区域按重叠率 (IOU) 判断每个区域是否包含对象使用算法选取出来的区域与实际区域通常不会完全重叠只会重叠一部分在学习的过程中我们需要根据手头上的真实区域预先判断选取出来的区域是否包含对象再告诉模型预测结果是否正确。判断选取区域是否包含对象会依据重叠率 (IOU - Intersection Over Union)所谓重叠率就是两个区域重叠的面积占两个区域合并的面积的比率如下图所示。我们可以规定重叠率大于 70% 的候选区域包含对象重叠率小于 30% 的区域不包含对象而重叠率介于 30% ~ 70% 的区域不应该参与学习这是为了给模型提供比较明确的数据使得学习效果更好。计算重叠率的代码如下如果两个区域没有重叠则重叠率会为 0def calc_iou(rect1, rect2): 计算两个区域重叠部分 / 合并部分的比率 (intersection over union) x1, y1, w1, h1 rect1 x2, y2, w2, h2 rect2 xi max(x1, x2) yi max(y1, y2) wi min(x1w1, x2w2) - xi hi min(y1h1, y2h2) - yi if wi 0 and hi 0: # 有重叠部分 area_overlap wi*hi area_all w1*h1 w2*h2 - area_overlap iou area_overlap / area_all else: # 没有重叠部分 iou 0 return iou原始论文如果你想看 RCNN 的原始论文可以到以下的地址https://arxiv.org/pdf/1311.2524.pdf使用 RCNN 识别图片中的人脸好了到这里你应该大致了解 RCNN 的实现原理接下来我们试着用 RCNN 学习识别一些图片。因为收集图片和标记图片非常累人为了偷懒这篇我还是使用现成的数据集。以下是包含人脸图片的数据集并且带了各个人脸所在的区域的标记格式是 (x1, y1, x2, y2)。下载需要注册帐号但不需要交钱。Checking your browser - reCAPTCHA下载解压后可以看到图片在 train/image_data 下标记在 bbox_train.csv 中。例如以下的图片对应 csv 中的以下标记Name,width,height,xmin,ymin,xmax,ymax 10001.jpg,612,408,192,199,230,235 10001.jpg,612,408,247,168,291,211 10001.jpg,612,408,321,176,366,222 10001.jpg,612,408,355,183,387,214数据的意义如下Name: 文件名width: 图片整体宽度height: 图片整体高度xmin: 人脸区域的左上角的 x 坐标ymin: 人脸区域的左上角的 y 坐标xmax: 人脸区域的右下角的 x 坐标ymax: 人脸区域的右下角的 y 坐标使用 RCNN 学习与识别这些图片中的人脸区域的代码如下import os import sys import torch import gzip import itertools import random import numpy import pandas import torchvision import cv2 from torch import nn from matplotlib import pyplot from collections import defaultdict # 各个区域缩放到的图片大小 REGION_IMAGE_SIZE (32, 32) # 分析目标的图片所在的文件夹 IMAGE_DIR ./784145_1347673_bundle_archive/train/image_data # 定义各个图片中人脸区域的 CSV 文件 BOX_CSV_PATH ./784145_1347673_bundle_archive/train/bbox_train.csv # 用于启用 GPU 支持 device torch.device(cuda if torch.cuda.is_available() else cpu) class MyModel(nn.Module): 识别是否人脸 (ResNet-18) def __init__(self): super().__init__() # Resnet 的实现 # 输出两个分类 [非人脸, 人脸] self.resnet torchvision.models.resnet18(num_classes2) def forward(self, x): # 应用 ResNet y self.resnet(x) return y def save_tensor(tensor, path): 保存 tensor 对象到文件 torch.save(tensor, gzip.GzipFile(path, wb)) def load_tensor(path): 从文件读取 tensor 对象 return torch.load(gzip.GzipFile(path, rb)) def image_to_tensor(img): 转换 opencv 图片对象到 tensor 对象 # 注意 opencv 是 BGR但对训练没有影响所以不用转为 RGB img cv2.resize(img, dsizeREGION_IMAGE_SIZE) arr numpy.asarray(img) t torch.from_numpy(arr) t t.transpose(0, 2) # 转换维度 H,W,C 到 C,W,H t t / 255.0 # 正规化数值使得范围在 0 ~ 1 return t def calc_iou(rect1, rect2): 计算两个区域重叠部分 / 合并部分的比率 (intersection over union) x1, y1, w1, h1 rect1 x2, y2, w2, h2 rect2 xi max(x1, x2) yi max(y1, y2) wi min(x1w1, x2w2) - xi hi min(y1h1, y2h2) - yi if wi 0 and hi 0: # 有重叠部分 area_overlap wi*hi area_all w1*h1 w2*h2 - area_overlap iou area_overlap / area_all else: # 没有重叠部分 iou 0 return iou def selective_search(img): 计算 opencv 图片中可能出现对象的区域只返回头 2000 个区域 # 算法参考 https://www.learnopencv.com/selective-search-for-object-detection-cpp-python/ s cv2.ximgproc.segmentation.createSelectiveSearchSegmentation() s.setBaseImage(img) s.switchToSelectiveSearchFast() boxes s.process() return boxes[:2000] def prepare_save_batch(batch, image_tensors, image_labels): 准备训练 - 保存单个批次的数据 # 生成输入和输出 tensor 对象 tensor_in torch.stack(image_tensors) # 维度: B,C,W,H tensor_out torch.tensor(image_labels, dtypetorch.long) # 维度: B # 切分训练集 (80%)验证集 (10%) 和测试集 (10%) random_indices torch.randperm(tensor_in.shape[0]) training_indices random_indices[:int(len(random_indices)*0.8)] validating_indices random_indices[int(len(random_indices)*0.8):int(len(random_indices)*0.9):] testing_indices random_indices[int(len(random_indices)*0.9):] training_set (tensor_in[training_indices], tensor_out[training_indices]) validating_set (tensor_in[validating_indices], tensor_out[validating_indices]) testing_set (tensor_in[testing_indices], tensor_out[testing_indices]) # 保存到硬盘 save_tensor(training_set, fdata/training_set.{batch}.pt) save_tensor(validating_set, fdata/validating_set.{batch}.pt) save_tensor(testing_set, fdata/testing_set.{batch}.pt) print(fbatch {batch} saved) def prepare(): 准备训练 # 数据集转换到 tensor 以后会保存在 data 文件夹下 if not os.path.isdir(data): os.makedirs(data) # 加载 csv 文件构建图片到区域列表的索引 { 图片名: [ 区域, 区域, .. ] } box_map defaultdict(lambda: []) df pandas.read_csv(BOX_CSV_PATH) for row in df.values: filename, width, height, x1, y1, x2, y2 row[:7] box_map[filename].append((x1, y1, x2-x1, y2-y1)) # 从图片里面提取人脸 (正样本) 和非人脸 (负样本) 的图片 batch_size 1000 batch 0 image_tensors [] image_labels [] for filename, true_boxes in box_map.items(): path os.path.join(IMAGE_DIR, filename) img cv2.imread(path) # 加载原始图片 candidate_boxes selective_search(img) # 查找候选区域 positive_samples 0 negative_samples 0 for candidate_box in candidate_boxes: # 如果候选区域和任意一个实际区域重叠率大于 70%则认为是正样本 # 如果候选区域和所有实际区域重叠率都小于 30%则认为是负样本 # 每个图片最多添加正样本数量 10 个负样本需要提供足够多负样本避免伪阳性判断 iou_list [ calc_iou(candidate_box, true_box) for true_box in true_boxes ] positive_index next((index for index, iou in enumerate(iou_list) if iou 0.70), None) is_negative all(iou 0.30 for iou in iou_list) result None if positive_index is not None: result True positive_samples 1 elif is_negative and negative_samples positive_samples 10: result False negative_samples 1 if result is not None: x, y, w, h candidate_box child_img img[y:yh, x:xw].copy() # 检验计算是否有问题 # cv2.imwrite(f{filename}_{x}_{y}_{w}_{h}_{int(result)}.png, child_img) image_tensors.append(image_to_tensor(child_img)) image_labels.append(int(result)) if len(image_tensors) batch_size: # 保存批次 prepare_save_batch(batch, image_tensors, image_labels) image_tensors.clear() image_labels.clear() batch 1 # 保存剩余的批次 if len(image_tensors) 10: prepare_save_batch(batch, image_tensors, image_labels) def train(): 开始训练 # 创建模型实例 model MyModel().to(device) # 创建损失计算器 loss_function torch.nn.CrossEntropyLoss() # 创建参数调整器 optimizer torch.optim.Adam(model.parameters()) # 记录训练集和验证集的正确率变化 training_accuracy_history [] validating_accuracy_history [] # 记录最高的验证集正确率 validating_accuracy_highest -1 validating_accuracy_highest_epoch 0 # 读取批次的工具函数 def read_batches(base_path): for batch in itertools.count(): path f{base_path}.{batch}.pt if not os.path.isfile(path): break yield [ t.to(device) for t in load_tensor(path) ] # 计算正确率的工具函数正样本和负样本的正确率分别计算再平均 def calc_accuracy(actual, predicted): predicted torch.max(predicted, 1).indices acc_positive ((actual 0.5) (predicted 0.5)).sum().item() / ((actual 0.5).sum().item() 0.00001) acc_negative ((actual 0.5) (predicted 0.5)).sum().item() / ((actual 0.5).sum().item() 0.00001) acc (acc_positive acc_negative) / 2 return acc # 划分输入和输出的工具函数 def split_batch_xy(batch, beginNone, endNone): # shape batch_size, channels, width, height batch_x batch[0][begin:end] # shape batch_size, num_labels batch_y batch[1][begin:end] return batch_x, batch_y # 开始训练过程 for epoch in range(1, 10000): print(fepoch: {epoch}) # 根据训练集训练并修改参数 model.train() training_accuracy_list [] for batch_index, batch in enumerate(read_batches(data/training_set)): # 切分小批次有助于泛化模型 training_batch_accuracy_list [] for index in range(0, batch[0].shape[0], 100): # 划分输入和输出 batch_x, batch_y split_batch_xy(batch, index, index100) # 计算预测值 predicted model(batch_x) # 计算损失 loss loss_function(predicted, batch_y) # 从损失自动微分求导函数值 loss.backward() # 使用参数调整器调整参数 optimizer.step() # 清空导函数值 optimizer.zero_grad() # 记录这一个批次的正确率torch.no_grad 代表临时禁用自动微分功能 with torch.no_grad(): training_batch_accuracy_list.append(calc_accuracy(batch_y, predicted)) # 输出批次正确率 training_batch_accuracy sum(training_batch_accuracy_list) / len(training_batch_accuracy_list) training_accuracy_list.append(training_batch_accuracy) print(fepoch: {epoch}, batch: {batch_index}: batch accuracy: {training_batch_accuracy}) training_accuracy sum(training_accuracy_list) / len(training_accuracy_list) training_accuracy_history.append(training_accuracy) print(ftraining accuracy: {training_accuracy}) # 检查验证集 model.eval() validating_accuracy_list [] for batch in read_batches(data/validating_set): batch_x, batch_y split_batch_xy(batch) predicted model(batch_x) validating_accuracy_list.append(calc_accuracy(batch_y, predicted)) validating_accuracy sum(validating_accuracy_list) / len(validating_accuracy_list) validating_accuracy_history.append(validating_accuracy) print(fvalidating accuracy: {validating_accuracy}) # 记录最高的验证集正确率与当时的模型状态判断是否在 20 次训练后仍然没有刷新记录 if validating_accuracy validating_accuracy_highest: validating_accuracy_highest validating_accuracy validating_accuracy_highest_epoch epoch save_tensor(model.state_dict(), model.pt) print(highest validating accuracy updated) elif epoch - validating_accuracy_highest_epoch 20: # 在 20 次训练后仍然没有刷新记录结束训练 print(stop training because highest validating accuracy not updated in 20 epoches) break # 使用达到最高正确率时的模型状态 print(fhighest validating accuracy: {validating_accuracy_highest}, ffrom epoch {validating_accuracy_highest_epoch}) model.load_state_dict(load_tensor(model.pt)) # 检查测试集 testing_accuracy_list [] for batch in read_batches(data/testing_set): batch_x, batch_y split_batch_xy(batch) predicted model(batch_x) testing_accuracy_list.append(calc_accuracy(batch_y, predicted)) testing_accuracy sum(testing_accuracy_list) / len(testing_accuracy_list) print(ftesting accuracy: {testing_accuracy}) # 显示训练集和验证集的正确率变化 pyplot.plot(training_accuracy_history, labeltraining) pyplot.plot(validating_accuracy_history, labelvaliding) pyplot.ylim(0, 1) pyplot.legend() pyplot.show() def eval_model(): 使用训练好的模型 # 创建模型实例加载训练好的状态然后切换到验证模式 model MyModel().to(device) model.load_state_dict(load_tensor(model.pt)) model.eval() # 询问图片路径并显示所有可能是人脸的区域 while True: try: # 选取可能出现对象的区域一览 image_path input(Image path: ) if not image_path: continue img cv2.imread(image_path) candidate_boxes selective_search(img) # 构建输入 image_tensors [] for candidate_box in candidate_boxes: x, y, w, h candidate_box child_img img[y:yh, x:xw].copy() image_tensors.append(image_to_tensor(child_img)) tensor_in torch.stack(image_tensors).to(device) # 预测输出 tensor_out model(tensor_in) # 使用 softmax 计算是人脸的概率 tensor_out nn.functional.softmax(tensor_out, dim1) tensor_out tensor_out[:,1].resize(tensor_out.shape[0]) # 判断概率大于 99% 的是人脸添加边框到图片并保存 img_output img.copy() indices torch.where(tensor_out 0.99)[0] result_boxes [] result_boxes_all [] for index in indices: box candidate_boxes[index] for exists_box in result_boxes_all: # 如果和现存找到的区域重叠度大于 30% 则跳过 if calc_iou(exists_box, box) 0.30: break else: result_boxes.append(box) result_boxes_all.append(box) for box in result_boxes: