大家好!
在本文中,我们将编写一个小程序来实时解决对象检测和识别(对象检测)问题。 该程序将使用iOS平台的Swift编程语言编写。 为了检测物体,我们将使用具有称为YOLOv3的体系结构的卷积神经网络。 在本文中,我们将学习如何使用CoreML框架在iOS中使用神经网络,对YOLOv3网络是什么以及如何使用和处理该网络的输出有一些了解。 我们还将检查程序的运行情况,并比较YOLOv3的几种变体:YOLOv3-tiny和YOLOv3-416。
文章结尾处将提供源代码,因此每个人都可以在其设备上测试神经网络的操作。
物体检测
首先,我们将简要了解在图像中检测物体(物体检测)的任务以及今天使用的工具。 我知道许多人都非常熟悉此主题,但是我仍然允许自己讲一些相关内容。
现在,借助卷积神经网络(卷积神经网络)(以下简称CNN)解决了计算机视觉领域的许多任务。 由于其结构,它们可以很好地从图像中提取特征。 CNN用于分类,识别,分割和许多其他用途。
流行的用于对象识别的CNN架构:
- R-CNN。 我们可以说第一个解决这个问题的模型。 像常规图像分类器一样工作。 图像的不同区域被馈送到网络输入,并为它们做出预测。 由于它运行单个图像数千次,因此非常慢。
- 快速R-CNN。 改进和更快的R-CNN版本以类似的原理工作,但是首先将整个图像馈入CNN输入,然后从接收到的内部表示中生成区域。 但是对于实时任务来说仍然很慢。
- 更快的R-CNN。 与以前的算法的主要区别在于,它不是使用选择性搜索算法,而是使用神经网络来选择区域以“记忆”它们。
- YOLO。 与以前的工作原理完全不同的工作原理根本不使用区域。 最快的。 有关它的更多详细信息将在本文中讨论。
- 固态硬盘 它在原理上类似于YOLO,但使用VGG16作为网络提取特征。 也非常快,适合实时工作。
- 功能金字塔网络(FPN)。 由于特征提取的功能,另一种类型的网络(如单发检测器)比SSD能够识别小物体更好。
- 视网膜网。 它使用FPN + ResNet的组合,并且由于特殊的误差功能(焦点损失)而具有更高的精度。
在本文中,我们将使用YOLO架构,即其最新的修改版YOLOv3。
为什么要YOLO?
YOLO或You Only Look One是CNN非常流行的体系结构,用于识别图像中的多个对象。 可以在
官方网站上获得有关该
网络的更完整信息,在同一位置上您可以找到出版物,其中详细描述了该网络的理论和数学组成部分,并描述了其培训过程。
与其他体系结构相比,此体系结构的主要特征是大多数系统将CNN多次应用于图像的不同区域;在YOLO中,CNN一次应用于整个图像。 网络将图像划分为一种网格,并预测边界框以及每个部分都存在所需对象的可能性。
这种方法的优点是,网络可以立即查看整个图像,并在检测和识别对象时考虑上下文。 YOLO还比R-CNN快1000倍,比Fast R-CNN快100倍。 在本文中,我们将在移动设备上启动网络以进行联机处理,因此这对我们来说是最重要的质量。
有关比较体系结构的更多详细信息,请参见
此处 。
YOLOv3
YOLOv3是YOLO体系结构的高级版本。 它由106个卷积层组成,比其前身YOLOv2更好地检测了小物体。 YOLOv3的主要特征是在输出处有三层,每层旨在检测不同大小的对象。
下图显示了其原理图结构:
YOLOv3-tiny -YOLOv3体系结构的裁剪版本,由较少的层组成(只有2个输出层)。 它预测较小的对象会更糟,并且适用于小型数据集。 但是,由于采用了截断的结构,网络权重占用了少量的内存(约35 MB),并且产生了更高的FPS。 因此,这种架构优选用于移动设备上。
我们正在编写一个用于物体识别的程序
有趣的部分开始了!
让我们创建一个应用程序,该应用程序将使用手机的摄像头实时识别图像中的各种对象。 所有代码都将以Swift 4.2编程语言编写并在iOS设备上运行。
在本教程中,我们将使用一个现成的网络,该网络具有在
COCO数据集上预先训练的比例。 它提供了80种不同的课程。 因此,我们的神经元将能够识别80个不同的物体。
从Darknet到CoreML
原始的YOLOv3架构是使用
Darknet框架实现的。
在iOS上,从版本11.0开始,有一个很棒的
CoreML库,可让您直接在设备上运行机器学习模型。 但是有一个限制:该
程序只能在运行iOS 11及更高版本的设备上运行。问题在于CoreML仅了解
.coreml模型的特定格式。 对于大多数流行的库,例如Tensorflow,Keras或XGBoost,可以直接转换为CoreML格式。 但是对于Darknet来说,没有这种可能性。 为了将保存并训练好的模型从Darknet转换为CoreML,可以使用各种选项,例如,将Darknet保存到
ONNX ,然后将其从ONNX转换为CoreML。
我们将使用一种更简单的方法,并使用YOLOv3的Keras实现。 动作算法是这样的:将Darknet权重加载到Keras模型中,将其保存为Keras格式,然后直接将其转换为CoreML。
- 下载Darknet。 从此处下载经过培训的Darknet-YOLOv3模型的文件。 在本文中,我将使用两种架构:YOLOv3-416和YOLOv3-tiny。 我们将同时需要cfg和weights文件。
- 从Darknet到Keras。 首先,克隆存储库 ,转到repo文件夹并运行以下命令:
python convert.py yolov3.cfg yolov3.weights yolo.h5
yolov3.cfg和yolov3.weights在哪里下载了Darknet文件。 因此,我们应该有一个扩展名为.h5的文件-这是Keras格式保存的YOLOv3模型。 - 从Keras到CoreML。 最后一步仍然存在。 为了将模型转换为CoreML,您需要运行python脚本(必须首先为python安装coremltools库):
import coremltools coreml_model = coremltools.converters.keras.convert( 'yolo.h5', input_names='image', image_input_names='image', input_name_shape_dict={'image': [None, 416, 416, 3]},
必须对两种型号YOLOv3-416和YOLOv3-tiny执行上述步骤。
完成所有这些操作后,我们有两个文件:yolo.mlmodel和yolo-tiny.mlmodel。 现在,您可以开始编写应用程序本身的代码。
创建一个iOS应用
我不会描述所有的应用程序代码,您可以在存储库中看到它们,并在本文结尾处提供了指向该链接的链接。 我只说我们有三个UIViewController-a:OnlineViewController,PhotoViewController和SettingsViewController。 第一个是摄像机的输出以及每帧物体的在线检测。 在第二个中,您可以拍摄照片或从图库中选择图片,然后在这些图像上测试网络。 第三个包含设置,您可以选择YOLOv3-416或YOLOv3-tiny模型,以及选择阈值IoU(联合上方的交集)和对象置信度(当前图像部分中存在对象的概率)。
在CoreML中加载模型在将训练后的模型从Darknet格式转换为CoreML之后,我们得到了一个扩展名为
.mlmodel的文件。 就我而言,我为模型YOLOv3-416和YOLOv3-tiny创建了两个文件:
yolo.mlmodel和
yolo-tiny.mlmodel 。 现在您可以将这些文件加载到Xcode项目中。
我们创建了ModelProvider类;它将存储当前模型和用于异步调用神经网络以执行的方法。 以这种方式加载模型:
private func loadModel(type: YOLOType) { do { self.model = try YOLO(type: type) } catch { assertionFailure("error creating model") } }
YOLO类直接负责加载
.mlmodel文件和处理模型输出。 下载模型文件:
var url: URL? = nil self.type = type switch type { case .v3_Tiny: url = Bundle.main.url(forResource: "yolo-tiny", withExtension:"mlmodelc") self.anchors = tiny_anchors case .v3_416: url = Bundle.main.url(forResource: "yolo", withExtension:"mlmodelc") self.anchors = anchors_416 } guard let modelURL = url else { throw YOLOError.modelFileNotFound } do { model = try MLModel(contentsOf: modelURL) } catch let error { print(error) throw YOLOError.modelCreationError }
完整的ModelProvider代码。 import UIKit import CoreML protocol ModelProviderDelegate: class { func show(predictions: [YOLO.Prediction]?, stat: ModelProvider.Statistics, error: YOLOError?) } @available(macOS 10.13, iOS 11.0, tvOS 11.0, watchOS 4.0, *) class ModelProvider { struct Statistics { var timeForFrame: Float var fps: Float } static let shared = ModelProvider(modelType: Settings.shared.modelType) var model: YOLO! weak var delegate: ModelProviderDelegate? var predicted = 0 var timeOfFirstFrameInSecond = CACurrentMediaTime() init(modelType type: YOLOType) { loadModel(type: type) } func reloadModel(type: YOLOType) { loadModel(type: type) } private func loadModel(type: YOLOType) { do { self.model = try YOLO(type: type) } catch { assertionFailure("error creating model") } } func predict(frame: UIImage) { DispatchQueue.global().async { do { let startTime = CACurrentMediaTime() let predictions = try self.model.predict(frame: frame) let elapsed = CACurrentMediaTime() - startTime self.showResultOnMain(predictions: predictions, elapsed: Float(elapsed), error: nil) } catch let error as YOLOError { self.showResultOnMain(predictions: nil, elapsed: -1, error: error) } catch { self.showResultOnMain(predictions: nil, elapsed: -1, error: YOLOError.unknownError) } } } private func showResultOnMain(predictions: [YOLO.Prediction]?, elapsed: Float, error: YOLOError?) { if let delegate = self.delegate { DispatchQueue.main.async { let fps = self.measureFPS() delegate.show(predictions: predictions, stat: ModelProvider.Statistics(timeForFrame: elapsed, fps: fps), error: error) } } } private func measureFPS() -> Float { predicted += 1 let elapsed = CACurrentMediaTime() - timeOfFirstFrameInSecond let currentFPSDelivered = Double(predicted) / elapsed if elapsed > 1 { predicted = 0 timeOfFirstFrameInSecond = CACurrentMediaTime() } return Float(currentFPSDelivered) } }
神经网络输出处理现在让我们弄清楚如何处理神经网络的输出并获得相应的边界框。 在Xcode中,如果选择模型文件,则可以看到它们是什么并看到输出层。
进入和退出YOLOv3-tiny。出入口YOLOv3-416。如上图所示,我们有3个用于YOLOv3-416,两个有用于YOLOv3-tiny输出层,在每个输出层中,预测了各种对象的边界框。
在这种情况下,这是一个常规的数字数组,让我们弄清楚如何解析它。
YOLOv3模型使用三层作为输出将图像划分为不同的网格,这些网格的像元大小具有以下值:8、16和32。假设我们在输入处具有416x416像素大小的图像,那么输出矩阵(栅格)的大小将为52x52 ,26x26和13x13(416/8 = 52、416 / 16 = 26和416/32 = 13)。 在YOLOv3-tiny的情况下,所有内容都是相同的,但是没有三个网格,而是两个:16和32,即26x26和13x13尺寸的矩阵。
启动加载的CoreML模型后,我们在输出上获得MLMultiArray类的两个(或三个)对象。 并且,如果您查看这些对象的shape属性,我们将看到以下图片(用于YOLOv3-tiny):
正如预期的那样,矩阵的尺寸将分别为26x26和13x13,但是数字255是什么意思呢? 如前所述,输出层是52x52、26x26和13x13矩阵。 事实是,此矩阵的每个元素都不是数字,而是一个向量。 即,输出层是三维矩阵。 此向量的维数为B x(5 + C),其中B是单元格中边界框的数量,C是类的数量。 5是哪里来的? 原因是:对于每个box-a,预测有一个对象(对象置信度)的概率是一个数,其余四个是预测的box-a的x,y,宽度和高度。 下图显示了此向量的示意图:
输出层的示意图(功能图)。对于我们的经过80个类别训练的网络,每个分区网格的每个单元格都预测有3个边界框-a,每个边界框-80个类别概率+对象置信度+ 4个负责该框a的位置和大小的数字。 总计:3 x(5 + 80)= 255。
要从MLMultiArray类获取这些值,最好使用指向数据数组和地址算术的原始指针:
let pointer = UnsafeMutablePointer<Double>(OpaquePointer(out.dataPointer))
现在,您需要处理255个元素的向量。 对于每个框,您需要获得80个类别的概率分布,可以使用softmax函数进行此操作。
什么是softmax函数转换向量
尺寸K变成相同尺寸的向量,其中每个坐标
所得向量由区间[0,1]中的实数表示,坐标之和为1。
其中K是向量的维数。
Swift的Softmax函数:
private func softmax(_ x: inout [Float]) { let len = vDSP_Length(x.count) var count = Int32(x.count) vvexpf(&x, x, &count) var sum: Float = 0 vDSP_sve(x, 1, &sum, len) vDSP_vsdiv(x, 1, &sum, &x, 1, len) }
要获取边界框-a的坐标和大小,您需要使用以下公式:
在哪里
-分别预测x,y坐标,宽度和高度,
是S型函数,并且
-三个框的锚点(anchor)值。 这些值是在培训期间确定的,并在Helpers.swift文件中设置:
let anchors1: [Float] = [116,90, 156,198, 373,326]
边界框位置计算的示意图。用于处理输出层的完整代码。 private func process(output out: MLMultiArray, name: String) throws -> [Prediction] { var predictions = [Prediction]() let grid = out.shape[out.shape.count-1].intValue let gridSize = YOLO.inputSize / Float(grid) let classesCount = labels.count print(out.shape) let pointer = UnsafeMutablePointer<Double>(OpaquePointer(out.dataPointer)) if out.strides.count < 3 { throw YOLOError.strideOutOfBounds } let channelStride = out.strides[out.strides.count-3].intValue let yStride = out.strides[out.strides.count-2].intValue let xStride = out.strides[out.strides.count-1].intValue func offset(ch: Int, x: Int, y: Int) -> Int { return ch * channelStride + y * yStride + x * xStride } for x in 0 ..< grid { for y in 0 ..< grid { for box_i in 0 ..< YOLO.boxesPerCell { let boxOffset = box_i * (classesCount + 5) let bbx = Float(pointer[offset(ch: boxOffset, x: x, y: y)]) let bby = Float(pointer[offset(ch: boxOffset + 1, x: x, y: y)]) let bbw = Float(pointer[offset(ch: boxOffset + 2, x: x, y: y)]) let bbh = Float(pointer[offset(ch: boxOffset + 3, x: x, y: y)]) let confidence = sigmoid(Float(pointer[offset(ch: boxOffset + 4, x: x, y: y)])) if confidence < confidenceThreshold { continue } let x_pos = (sigmoid(bbx) + Float(x)) * gridSize let y_pos = (sigmoid(bby) + Float(y)) * gridSize let width = exp(bbw) * self.anchors[name]![2 * box_i] let height = exp(bbh) * self.anchors[name]![2 * box_i + 1] for c in 0 ..< 80 { classes[c] = Float(pointer[offset(ch: boxOffset + 5 + c, x: x, y: y)]) } softmax(&classes) let (detectedClass, bestClassScore) = argmax(classes) let confidenceInClass = bestClassScore * confidence if confidenceInClass < confidenceThreshold { continue } predictions.append(Prediction(classIndex: detectedClass, score: confidenceInClass, rect: CGRect(x: CGFloat(x_pos - width / 2), y: CGFloat(y_pos - height / 2), width: CGFloat(width), height: CGFloat(height)))) } } } return predictions }
非最大抑制收到边界框的坐标和大小以及图像中找到的所有对象的相应概率后,就可以开始在图片上方绘制它们。 但是有一个问题! 当为一个对象预测几个概率很高的框时,可能会出现这种情况。 在这种情况下该怎么办? 在这里,一个叫做非最大抑制的相当简单的算法对我们有所帮助。
算法如下:
- 我们正在寻找具有属于该对象的最高概率的边界框。
- 我们遍历了也属于该对象的所有边界框。
- 如果与第一个边界框的“交集相交”(IoU)大于指定的阈值,则将其删除。
IoU使用一个简单的公式计算:
IoU的计算。 static func IOU(a: CGRect, b: CGRect) -> Float { let areaA = a.width * a.height if areaA <= 0 { return 0 } let areaB = b.width * b.height if areaB <= 0 { return 0 } let intersection = a.intersection(b) let intersectionArea = intersection.width * intersection.height return Float(intersectionArea / (areaA + areaB - intersectionArea)) }
非最大抑制。 private func nonMaxSuppression(boxes: inout [Prediction], threshold: Float) { var i = 0 while i < boxes.count { var j = i + 1 while j < boxes.count { let iou = YOLO.IOU(a: boxes[i].rect, b: boxes[j].rect) if iou > threshold { if boxes[i].score > boxes[j].score { if boxes[i].classIndex == boxes[j].classIndex { boxes.remove(at: j) } else { j += 1 } } else { if boxes[i].classIndex == boxes[j].classIndex { boxes.remove(at: i) j = i + 1 } else { j += 1 } } } else { j += 1 } } i += 1 } }
在那之后,直接与神经网络预测结果一起工作可以被认为是完整的。 接下来,您需要编写函数和类以从相机获取素材,在屏幕上显示图像并渲染预测的边界框。 我不会在本文中描述所有这些代码,但是可以在存储库中查看它们。
还值得一提的是,在处理在线图像时,我对边界框进行了一些平滑处理,在这种情况下,这是对最近30帧中预测正方形的位置和大小的通常平均。
测试程序
现在我们测试应用程序。
让我再次提醒您:应用程序中有三个ViewController,一个用于处理照片或快照,一个用于处理在线视频流,第三个用于设置网络。
让我们从第三个开始。 在其中,您可以选择YOLOv3-tiny或YOLOv3-416两个模型之一,选择置信度阈值和IoU阈值,还可以启用或禁用在线抗锯齿。
现在,让我们看看受过训练的神经元如何处理真实图像,为此,我们从图库中拍摄一张照片,并将其通过网络传递。 下图显示了使用不同设置的YOLOv3-tiny的结果。
YOLOv3-tiny的不同操作模式。 左图显示了通常的操作模式。 在中间-阈值IoU = 1 好像缺少非最大抑制。 右侧是对象置信度的低阈值,即 显示所有可能的边界框。以下是YOLOv3-416的结果。 您可能会注意到,与YOLOv3-tiny相比,生成的帧更正确,并且可以识别图像中较小的对象,这与第三输出层的工作相对应。
使用YOLOv3-416处理的图像。打开在线操作模式时,将处理每个帧并进行预测,
然后在iPhone XS上进行测试,因此对于两个网络选项来说,结果都是可以接受的。
YOLOv3-tiny的平均速度为30-32 fps,YOLOv3-416-的平均速度为23到25 fps。 在其上进行测试的设备生产率很高,因此在较早的型号上结果可能有所不同,在这种情况下,当然最好使用YOLOv3-tiny。 另一个要点:yolo-tiny.mlmodel(YOLOv3-tiny)约占35 MB,而yolo.mlmodel(YOLOv3 -16)约重250 MB,这是非常重要的区别。
结论
结果,编写了一个iOS应用程序,该应用程序在神经网络的帮助下可以识别图像中的对象。 我们看到了如何使用CoreML库以及如何使用它执行各种预先训练的模型(顺便说一下,您也可以使用它进行训练)。 使用YOLOv3网络解决了对象识别问题。 在iPhone XS上,该网络(YOLOv3-tiny)能够以每秒约30帧的频率处理图像,对于实时操作而言已经足够了。
完整的应用程序代码可以在
GitHub上查看。