教程:在 ML.NET 中使用 ONNX 检测对象

了解如何在 ML.NET 中使用预先训练的 ONNX 模型来检测图像中的对象。

从头开始训练对象检测模型需要设置数百万个参数、大量的标记训练数据和大量的计算资源(数百个 GPU 小时)。 使用预先训练的模型,可以快捷方式训练过程。

本教程中,您将学习如何:

  • 了解问题
  • 了解 ONNX 是什么以及它如何使用 ML.NET
  • 了解模型
  • 重复使用预先训练的模型
  • 使用加载的模型检测对象

先决条件

ONNX 对象检测示例概述

此示例创建一个 .NET Core 控制台应用程序,该应用程序使用预先训练的深度学习 ONNX 模型检测图像中的对象。 可以在 GitHub 上的 dotnet/machinelearning-samples 存储库 中找到此示例的代码。

什么是对象检测?

对象检测是计算机视觉问题。 虽然与图像分类密切相关,但对象检测以更精细的规模执行图像分类。 对象检测同时定位图像中的实体 对实体进行分类。 对象检测模型通常使用深度学习和神经网络进行训练。 有关详细信息,请参阅 深度学习与机器学习

当图像包含不同类型的多个对象时,请使用对象检测。

显示图像分类与对象分类的屏幕截图。

对象检测的一些用例包括:

  • 自动驾驶汽车
  • 机器人
  • 人脸检测
  • 工作区安全
  • 对象计数
  • 活动识别

选择深度学习模型

深度学习是机器学习的一部分。 若要训练深度学习模型,需要大量数据。 数据中的模式由一系列层表示。 数据中的关系编码为包含权重的层之间的连接。 权重越高,关系就越强。 这一系列层和连接统称为人工神经网络。 网络中的层越多,它的“深度”就越深,使其成为深度神经网络。

有不同类型的神经网络,最常见的是多层感知器(MLP)、卷积神经网络(CNN)和循环神经网络(RNN)。 最基本的是 MLP,它将一组输入映射到一组输出。 当数据没有空间或时间组件时,此神经网络是很好的。 CNN 利用卷积层来处理数据中包含的空间信息。 CNN 的一个很好的用例是图像处理来检测图像区域中是否存在特征(例如,图像中心是否有鼻子?)。 最后,RNN 允许将状态或内存的持久性用作输入。 RNN 用于时序分析,其中事件的顺序排序和上下文非常重要。

了解模型

对象检测是图像处理任务。 因此,用于解决此问题的大多数深度学习模型都是 CNN。 本教程中使用的模型是 Tiny YOLOv2 模型,这是 Redmon 和 Farhadi 介绍的 YOLOv2 模型的更紧凑版本:“YOLO9000:更好、更快、更强”。 Tiny YOLOv2 在 Pascal VOC 数据集上训练,由 15 个层组成,可以预测 20 个不同的对象类。 由于 Tiny YOLOv2 是原始 YOLOv2 模型的精简版本,因此在速度和准确性之间进行权衡。 可以使用 Netron 等工具可视化构成模型的不同层。 检查模型将产生构成神经网络的所有层之间的连接的映射,其中每个层将包含层的名称以及相应输入/输出的维度。 用于描述模型的输入和输出的数据结构称为张量。 张量可以被视为一种在 N 维度中存储数据的容器。 对于 Tiny YOLOv2,输入层的名称是 image ,它需要维度 3 x 416 x 416的张量。 输出层的名称是 grid 并生成维度 125 x 13 x 13的输出张量。

将输入层拆分为隐藏层,然后输出层

YOLO 模型采用图像 3(RGB) x 416px x 416px。 模型采用此输入,并通过不同的层传递以生成输出。 输出将输入图像划分为 13 x 13 网格,网格中的每个单元格由值组成 125

什么是 ONNX 模型?

开放神经网络交换(ONNX)是 AI 模型的开放源代码格式。 ONNX 支持框架之间的互作性。 这意味着可以在许多常用的机器学习框架之一(如 PyTorch)中训练模型,将其转换为 ONNX 格式,并在其他框架(如 ML.NET)中使用 ONNX 模型。 若要了解详细信息,请访问 ONNX 网站

ONNX 支持的格式使用情况的图表。

预训练的 Tiny YOLOv2 模型以 ONNX 格式存储,该格式是对模型层及其学习模式的序列化表示。 在 ML.NET 中,与 ONNX 的互操作性是通过 ImageAnalyticsOnnxTransformer NuGet 包实现的。 该 ImageAnalytics 包包含一系列转换,用于获取图像并将其编码为数值,这些数值可用作预测或训练管道的输入。 该 OnnxTransformer 包利用 ONNX 运行时加载 ONNX 模型,并使用它根据提供的输入进行预测。

ONNX 文件的数据流进入 ONNX 运行时。

设置 .NET 控制台项目

现在,你已大致了解 ONNX 是什么以及 Tiny YOLOv2 的工作原理,现在可以生成应用程序了。

创建控制台应用程序

  1. 创建名为“ObjectDetection”的 C# 控制台应用程序 。 单击“下一步”按钮。

  2. 选择 .NET 8 作为要使用的框架。 单击“创建” 按钮。

  3. 安装 Microsoft.ML NuGet 包

    注释

    此示例使用提到的 NuGet 包的最新稳定版本,除非另有说明。

    • 在解决方案资源管理器中,右键单击项目并选择“ 管理 NuGet 包”。
    • 选择“nuget.org”作为包源,选择“浏览”选项卡,搜索 Microsoft.ML
    • 选择“ 安装 ”按钮。
    • 选择“预览更改”对话框中的“确定”按钮,然后选择“许可接受”对话框中的“我接受”按钮(如果同意列出的程序包的许可条款)。
    • 重复这些步骤用于 Microsoft.Windows.CompatibilityMicrosoft.ML.ImageAnalyticsMicrosoft.ML.OnnxTransformerMicrosoft.ML.OnnxRuntime

准备数据和预先训练的模型

  1. 下载 项目资产目录 zip 文件 并解压缩。

  2. assets 目录复制到 ObjectDetection 项目目录中。 此目录及其子目录包含本教程所需的图像文件(除 Tiny YOLOv2 模型外,后者将在下一步中下载并添加)。

  3. ONNX 模型动物园下载 Tiny YOLOv2 模型。

  4. model.onnx 文件复制到 ObjectDetection 项目 assets\Model 目录中,并将其重命名为 TinyYolo2_model.onnx。 此目录包含本教程所需的模型。

  5. 在解决方案资源管理器中,右键单击资产目录和子目录中的每个文件,然后选择 “属性”。 在 高级 下,将 复制到输出目录 的值更改为 若较新则复制

创建类并定义路径

打开 Program.cs 文件,并将以下附加 using 指令添加到文件顶部:

using System.Drawing;
using System.Drawing.Drawing2D;
using ObjectDetection.YoloParser;
using ObjectDetection.DataStructures;
using ObjectDetection;
using Microsoft.ML;

接下来,定义各种资产的路径。

  1. 首先,在GetAbsolutePathProgram.cs文件的底部创建方法。

    string GetAbsolutePath(string relativePath)
    {
        FileInfo _dataRoot = new FileInfo(typeof(Program).Assembly.Location);
        string assemblyFolderPath = _dataRoot.Directory.FullName;
    
        string fullPath = Path.Combine(assemblyFolderPath, relativePath);
    
        return fullPath;
    }
    
  2. 然后,在 using 指令下方创建字段来存储资产的位置。

    var assetsRelativePath = @"../../../assets";
    string assetsPath = GetAbsolutePath(assetsRelativePath);
    var modelFilePath = Path.Combine(assetsPath, "Model", "TinyYolo2_model.onnx");
    var imagesFolder = Path.Combine(assetsPath, "images");
    var outputFolder = Path.Combine(assetsPath, "images", "output");
    

向项目添加新目录以存储输入数据和预测类。

解决方案资源管理器中,右键单击项目,然后选择“ 添加新>文件夹”。 当新文件夹出现在解决方案资源管理器中时,将其命名为“DataStructures”。

在新建 的 DataStructures 目录中创建输入数据类。

  1. 解决方案资源管理器中,右键单击 DataStructures 目录,然后选择“ 添加新>”。

  2. 在“ 添加新项 ”对话框中,选择“ ”并将“ 名称 ”字段更改为 ImageNetData.cs。 然后选择“添加”。

    ImageNetData.cs文件将在代码编辑器中打开。 将以下 using 指令添加到 ImageNetData.cs顶部:

    using System.Collections.Generic;
    using System.IO;
    using System.Linq;
    using Microsoft.ML.Data;
    

    删除现有类定义,并将该 ImageNetData 类的以下代码添加到 ImageNetData.cs 文件中:

    public class ImageNetData
    {
        [LoadColumn(0)]
        public string ImagePath;
    
        [LoadColumn(1)]
        public string Label;
    
        public static IEnumerable<ImageNetData> ReadFromFile(string imageFolder)
        {
            return Directory
                .GetFiles(imageFolder)
                .Where(filePath => Path.GetExtension(filePath) != ".md")
                .Select(filePath => new ImageNetData { ImagePath = filePath, Label = Path.GetFileName(filePath) });
        }
    }
    

    ImageNetData 是输入图像数据类,具有以下 String 字段:

    • ImagePath 包含存储映像的路径。
    • Label 包含文件的名称。

    此外, ImageNetData 还包含一种方法 ReadFromFile ,该方法加载存储在指定路径中的 imageFolder 多个图像文件,并将其作为对象集合 ImageNetData 返回。

DataStructures 目录中创建预测类。

  1. 解决方案资源管理器中,右键单击 DataStructures 目录,然后选择“ 添加新>”。

  2. 在“ 添加新项 ”对话框中,选择“ ”并将“ 名称 ”字段更改为 ImageNetPrediction.cs。 然后选择“添加”。

    ImageNetPrediction.cs文件将在代码编辑器中打开。 将以下 using 指令添加到 ImageNetPrediction.cs顶部:

    using Microsoft.ML.Data;
    

    删除现有类定义,并将该 ImageNetPrediction 类的以下代码添加到 ImageNetPrediction.cs 文件中:

    public class ImageNetPrediction
    {
        [ColumnName("grid")]
        public float[] PredictedLabels;
    }
    

    ImageNetPrediction 是预测数据类,具有以下 float[] 字段:

    • PredictedLabels 包含图像中检测到的每个边界框的维度、对象性分数和类概率。

初始化变量

MLContext 类是所有 ML.NET作的起点,初始化mlContext会创建一个新的 ML.NET 环境,该环境可在模型创建工作流对象之间共享。 在概念上,它类似于 Entity Framework 中的DBContext

mlContext通过在字段下方MLContext添加以下行,使用新实例outputFolder初始化变量。

MLContext mlContext = new MLContext();

创建分析器以处理后模型输出

模型将图像细分为 13 x 13 网格,其中每个网格单元都是 32px x 32px。 每个网格单元包含 5 个潜在的对象边界框。 边界框包含 25 个元素:

左侧为网格示例,右侧为边界框示例

  • x 边界框中心的 x 位置相对于与之关联的网格单元格。
  • y 边界框中心相对于其关联的网格单元格的 y 位置。
  • w 边界框的宽度。
  • h 边界框的高度。
  • o 边界框中存在对象的置信度值,也称为对象性分数。
  • p1-p20 模型预测的每个 20 个类的类概率。

总共有 25 个元素描述每个边界框,构成每个网格单元中包含的 125 个元素。

预训练的 ONNX 模型生成的输出是一个长度为 21125 的浮点数组,表示具有维度 125 x 13 x 13 的张量的元素。 若要将模型生成的预测转换为张量,需要执行一些后处理工作。 为此,请创建一组类来帮助分析输出。

向项目添加新目录以组织分析程序类集。

  1. 解决方案资源管理器中,右键单击项目,然后选择“ 添加新>文件夹”。 当新文件夹出现在解决方案资源管理器中时,将其命名为“YoloParser”。

创建边界框和尺寸

模型输出的数据包含图像中对象的边界框的坐标和尺寸。 为维度创建基类。

  1. 解决方案资源管理器中,右键单击 YoloParser 目录,然后选择“ 添加新>”。

  2. 在“ 添加新项 ”对话框中,选择“ ”并将“ 名称 ”字段更改为 DimensionsBase.cs。 然后选择“添加”。

    DimensionsBase.cs文件将在代码编辑器中打开。 删除所有 using 指令和现有类定义。

    DimensionsBase 类的以下代码添加到 DimensionsBase.cs 文件:

    public class DimensionsBase
    {
        public float X { get; set; }
        public float Y { get; set; }
        public float Height { get; set; }
        public float Width { get; set; }
    }
    

    DimensionsBase 具有以下 float 属性:

    • X 包含对象沿 x 轴的位置。
    • Y 包含对象沿 y 轴的位置。
    • Height 包含对象的高度。
    • Width 包含对象的宽度。

接下来,为边界框创建一个类。

  1. 解决方案资源管理器中,右键单击 YoloParser 目录,然后选择“ 添加新>”。

  2. 在“ 添加新项 ”对话框中,选择“ ”并将“ 名称 ”字段更改为 YoloBoundingBox.cs。 然后选择“添加”。

    YoloBoundingBox.cs文件将在代码编辑器中打开。 将以下 using 指令添加到 YoloBoundingBox.cs顶部:

    using System.Drawing;
    

    就在现有类定义上方,添加一个新的类定义,名为BoundingBoxDimensions,它继承自DimensionsBase类,以包含相应边界框的维度。

    public class BoundingBoxDimensions : DimensionsBase { }
    

    删除现有 YoloBoundingBox 类定义,并将该 YoloBoundingBox 类的以下代码添加到 YoloBoundingBox.cs 文件中:

    public class YoloBoundingBox
    {
        public BoundingBoxDimensions Dimensions { get; set; }
    
        public string Label { get; set; }
    
        public float Confidence { get; set; }
    
        public RectangleF Rect
        {
            get { return new RectangleF(Dimensions.X, Dimensions.Y, Dimensions.Width, Dimensions.Height); }
        }
    
        public Color BoxColor { get; set; }
    }
    

    YoloBoundingBox 具有以下属性:

    • Dimensions 包含边界框的尺寸。
    • Label 包含边界框中检测到的对象类别。
    • Confidence 包含类的置信度。
    • Rect 包含边界框维度的矩形表示形式。
    • BoxColor 包含与用于在图像上绘制的相应类关联的颜色。

创建分析程序

现在已创建维度和边界框的类,现在可以创建分析程序了。

  1. 解决方案资源管理器中,右键单击 YoloParser 目录,然后选择“ 添加新>”。

  2. 在“ 添加新项 ”对话框中,选择“ ”并将“ 名称 ”字段更改为 YoloOutputParser.cs。 然后选择“添加”。

    YoloOutputParser.cs文件将在代码编辑器中打开。 将以下 using 指令添加到 YoloOutputParser.cs顶部:

    using System;
    using System.Collections.Generic;
    using System.Drawing;
    using System.Linq;
    

    在现有 YoloOutputParser 类定义中,添加一个嵌套类,其中包含图像中每个单元格的维度。 在YoloOutputParser类定义的顶部为继承自DimensionsBase类的CellDimensions类添加以下代码。

    class CellDimensions : DimensionsBase { }
    
  3. 在类定义中 YoloOutputParser ,添加以下常量和字段。

    public const int ROW_COUNT = 13;
    public const int COL_COUNT = 13;
    public const int CHANNEL_COUNT = 125;
    public const int BOXES_PER_CELL = 5;
    public const int BOX_INFO_FEATURE_COUNT = 5;
    public const int CLASS_COUNT = 20;
    public const float CELL_WIDTH = 32;
    public const float CELL_HEIGHT = 32;
    
    private int channelStride = ROW_COUNT * COL_COUNT;
    
    • ROW_COUNT 是图像划分为网格中的行数。
    • COL_COUNT 是图像划分为网格中的列数。
    • CHANNEL_COUNT 是网格的一个单元格中包含的值总数。
    • BOXES_PER_CELL 是单元格中的包围框数量,
    • BOX_INFO_FEATURE_COUNT 是框中所包含的特征数(x,y,高度,宽度,置信度)。
    • CLASS_COUNT 是每个边界框中所包含的类预测数。
    • CELL_WIDTH 是图像网格中一个单元格的宽度。
    • CELL_HEIGHT 是图像网格中一个单元格的高度。
    • channelStride 是网格中当前单元格的起始位置。

    当模型进行预测(也称为评分)时,它会将输入图像划分为单元格大小为13 x 13416px x 416px网格。 每个单元格都包含 。32px x 32px 在每个单元格中,有 5 个边界框,每个框包含 5 个特征(x、y、宽度、高度、置信度)。 此外,每个边界框都包含每个类的概率,在本例中为 20。 因此,每个单元格包含 125 条信息(5 个特征 + 20 个类概率)。

为所有 5 个边界框在channelStride下创建锚点列表:

private float[] anchors = new float[]
{
    1.08F, 1.19F, 3.42F, 4.41F, 6.63F, 11.38F, 9.42F, 5.11F, 16.62F, 10.52F
};

定位点是边界框的预定义高度和宽度比率。 模型检测到的大多数对象或类具有类似的比率。 在涉及到创建边界框时,这一点非常有价值。 不预测边界框,而是计算相对于预定义尺寸的偏移量,从而减少预测边界框所需的计算量。 通常,这些定位点比率是根据所使用的数据集计算的。 在这种情况下,由于数据集已知且已预计算值,因此可以硬编码定位点。

接下来,定义模型将预测的标签或类。 此模型预测 20 个类,这是原始 YOLOv2 模型预测的类总数的子集。

anchors的下方添加你的标签列表。

private string[] labels = new string[]
{
    "aeroplane", "bicycle", "bird", "boat", "bottle",
    "bus", "car", "cat", "chair", "cow",
    "diningtable", "dog", "horse", "motorbike", "person",
    "pottedplant", "sheep", "sofa", "train", "tvmonitor"
};

每个类都有关联的颜色。 在以下位置 labels分配类颜色:

private static Color[] classColors = new Color[]
{
    Color.Khaki,
    Color.Fuchsia,
    Color.Silver,
    Color.RoyalBlue,
    Color.Green,
    Color.DarkOrange,
    Color.Purple,
    Color.Gold,
    Color.Red,
    Color.Aquamarine,
    Color.Lime,
    Color.AliceBlue,
    Color.Sienna,
    Color.Orchid,
    Color.Tan,
    Color.LightPink,
    Color.Yellow,
    Color.HotPink,
    Color.OliveDrab,
    Color.SandyBrown,
    Color.DarkTurquoise
};

创建帮助程序函数

后续处理阶段涉及一系列步骤。 为此,可以使用多种辅助方法。

分析器使用的帮助程序方法包括:

  • Sigmoid 应用输出介于 0 和 1 之间的数字的 sigmoid 函数。
  • Softmax 将输入向量规范化为概率分布。
  • GetOffset 将一维模型输出中的元素映射到张量中的 125 x 13 x 13 相应位置。
  • ExtractBoundingBoxes 使用 GetOffset 模型输出中的方法提取边界框维度。
  • GetConfidence 提取置信度值,该值指示模型检测到对象的方式,并使用 Sigmoid 函数将其转换为百分比。
  • MapBoundingBoxToCell 使用边界框的尺寸,将其映射到图像中的相应单元格。
  • ExtractClasses 使用 GetOffset 该方法从模型输出中提取边界框的类预测,并使用该方法将其转换为概率分布 Softmax
  • GetTopResult 从概率最高的预测类列表中选择类。
  • IntersectionOverUnion 筛选概率较低的重叠边界框。

在列表 classColors下方为所有帮助程序方法添加代码。

private float Sigmoid(float value)
{
    var k = (float)Math.Exp(value);
    return k / (1.0f + k);
}

private float[] Softmax(float[] values)
{
    var maxVal = values.Max();
    var exp = values.Select(v => Math.Exp(v - maxVal));
    var sumExp = exp.Sum();

    return exp.Select(v => (float)(v / sumExp)).ToArray();
}

private int GetOffset(int x, int y, int channel)
{
    // YOLO outputs a tensor that has a shape of 125x13x13, which 
    // WinML flattens into a 1D array.  To access a specific channel 
    // for a given (x,y) cell position, we need to calculate an offset
    // into the array
    return (channel * this.channelStride) + (y * COL_COUNT) + x;
}

private BoundingBoxDimensions ExtractBoundingBoxDimensions(float[] modelOutput, int x, int y, int channel)
{
    return new BoundingBoxDimensions
    {
        X = modelOutput[GetOffset(x, y, channel)],
        Y = modelOutput[GetOffset(x, y, channel + 1)],
        Width = modelOutput[GetOffset(x, y, channel + 2)],
        Height = modelOutput[GetOffset(x, y, channel + 3)]
    };
}

private float GetConfidence(float[] modelOutput, int x, int y, int channel)
{
    return Sigmoid(modelOutput[GetOffset(x, y, channel + 4)]);
}

private CellDimensions MapBoundingBoxToCell(int x, int y, int box, BoundingBoxDimensions boxDimensions)
{
    return new CellDimensions
    {
        X = ((float)x + Sigmoid(boxDimensions.X)) * CELL_WIDTH,
        Y = ((float)y + Sigmoid(boxDimensions.Y)) * CELL_HEIGHT,
        Width = (float)Math.Exp(boxDimensions.Width) * CELL_WIDTH * anchors[box * 2],
        Height = (float)Math.Exp(boxDimensions.Height) * CELL_HEIGHT * anchors[box * 2 + 1],
    };
}

public float[] ExtractClasses(float[] modelOutput, int x, int y, int channel)
{
    float[] predictedClasses = new float[CLASS_COUNT];
    int predictedClassOffset = channel + BOX_INFO_FEATURE_COUNT;
    for (int predictedClass = 0; predictedClass < CLASS_COUNT; predictedClass++)
    {
        predictedClasses[predictedClass] = modelOutput[GetOffset(x, y, predictedClass + predictedClassOffset)];
    }
    return Softmax(predictedClasses);
}

private ValueTuple<int, float> GetTopResult(float[] predictedClasses)
{
    return predictedClasses
        .Select((predictedClass, index) => (Index: index, Value: predictedClass))
        .OrderByDescending(result => result.Value)
        .First();
}

private float IntersectionOverUnion(RectangleF boundingBoxA, RectangleF boundingBoxB)
{
    var areaA = boundingBoxA.Width * boundingBoxA.Height;

    if (areaA <= 0)
        return 0;

    var areaB = boundingBoxB.Width * boundingBoxB.Height;

    if (areaB <= 0)
        return 0;

    var minX = Math.Max(boundingBoxA.Left, boundingBoxB.Left);
    var minY = Math.Max(boundingBoxA.Top, boundingBoxB.Top);
    var maxX = Math.Min(boundingBoxA.Right, boundingBoxB.Right);
    var maxY = Math.Min(boundingBoxA.Bottom, boundingBoxB.Bottom);

    var intersectionArea = Math.Max(maxY - minY, 0) * Math.Max(maxX - minX, 0);

    return intersectionArea / (areaA + areaB - intersectionArea);
}

定义所有帮助程序方法后,就可以使用它们来处理模型输出。

IntersectionOverUnion 方法下方,创建 ParseOutputs 用于处理模型生成的输出的方法。

public IList<YoloBoundingBox> ParseOutputs(float[] yoloModelOutputs, float threshold = .3F)
{

}

ParseOutputs 方法中创建一个列表来存储边界框并定义变量。

var boxes = new List<YoloBoundingBox>();

每个图像被划分为一个网格,由13 x 13个单元格组成。 每个单元格包含五个边界框。 在 boxes 变量下方,添加代码以处理每个单元格中的所有框。

for (int row = 0; row < ROW_COUNT; row++)
{
    for (int column = 0; column < COL_COUNT; column++)
    {
        for (int box = 0; box < BOXES_PER_CELL; box++)
        {

        }
    }
}

在最内部循环中,计算一维模型输出中当前框的起始位置。

var channel = (box * (CLASS_COUNT + BOX_INFO_FEATURE_COUNT));

直接在其下方,使用 ExtractBoundingBoxDimensions 方法获取当前边界框的尺寸。

BoundingBoxDimensions boundingBoxDimensions = ExtractBoundingBoxDimensions(yoloModelOutputs, row, column, channel);

然后,使用GetConfidence 方法来获取当前边界框的置信度。

float confidence = GetConfidence(yoloModelOutputs, row, column, channel);

之后,使用 MapBoundingBoxToCell 该方法将当前边界框映射到正在处理的当前单元格。

CellDimensions mappedBoundingBox = MapBoundingBoxToCell(row, column, box, boundingBoxDimensions);

在进行进一步处理之前,请检查置信度值是否大于提供的阈值。 如果不是,请继续处理下一个边界框。

if (confidence < threshold)
    continue;

否则,请继续处理输出。 下一步是使用 ExtractClasses 该方法获取当前边界框的预测类的概率分布。

float[] predictedClasses = ExtractClasses(yoloModelOutputs, row, column, channel);

然后,使用 GetTopResult 该方法获取当前框概率最高的类的值和索引,并计算其分数。

var (topResultIndex, topResultScore) = GetTopResult(predictedClasses);
var topScore = topResultScore * confidence;

使用topScore再次保留那些超过指定阈值的边界框。

if (topScore < threshold)
    continue;

最后,如果当前边界框超出阈值,请创建新 BoundingBox 对象并将其添加到 boxes 列表中。

boxes.Add(new YoloBoundingBox()
{
    Dimensions = new BoundingBoxDimensions
    {
        X = (mappedBoundingBox.X - mappedBoundingBox.Width / 2),
        Y = (mappedBoundingBox.Y - mappedBoundingBox.Height / 2),
        Width = mappedBoundingBox.Width,
        Height = mappedBoundingBox.Height,
    },
    Confidence = topScore,
    Label = labels[topResultIndex],
    BoxColor = classColors[topResultIndex]
});

处理图像中的所有单元格后,返回 boxes 列表。 在方法中 ParseOutputs 最外部的 for-loop 下面添加以下 return 语句。

return boxes;

筛选重叠框

现在,从模型输出中提取了所有高度自信的边界框,需要执行额外的筛选来删除重叠的图像。 添加方法下面FilterBoundingBoxes调用ParseOutputs的方法:

public IList<YoloBoundingBox> FilterBoundingBoxes(IList<YoloBoundingBox> boxes, int limit, float threshold)
{

}

FilterBoundingBoxes 方法中,首先创建一个与检测到的框大小相同的数组,并将所有槽标记为活动或已准备好进行处理。

var activeCount = boxes.Count;
var isActiveBoxes = new bool[boxes.Count];

for (int i = 0; i < isActiveBoxes.Length; i++)
    isActiveBoxes[i] = true;

然后,根据置信度按降序对包含边界框的列表进行排序。

var sortedBoxes = boxes.Select((b, i) => new { Box = b, Index = i })
                    .OrderByDescending(b => b.Box.Confidence)
                    .ToList();

之后,创建一个列表来保存筛选的结果。

var results = new List<YoloBoundingBox>();

通过循环访问每个边界框开始处理每个边界框。

for (int i = 0; i < boxes.Count; i++)
{

}

在这个 for 循环中,检查当前边界框是否可以处理。

if (isActiveBoxes[i])
{

}

如果是,请将边界框添加到结果列表中。 如果结果超出了要提取的框的指定限制,则中断循环。 在 if-statement 中添加以下代码。

var boxA = sortedBoxes[i].Box;
results.Add(boxA);

if (results.Count >= limit)
    break;

否则,请查看相邻边界框。 在框限制检查下方添加以下代码。

for (var j = i + 1; j < boxes.Count; j++)
{

}

与第一个框一样,如果相邻框处于活动状态或已准备好进行处理,请使用 IntersectionOverUnion 该方法检查第一个框和第二个框是否超出指定的阈值。 将以下代码添加到最里面的 for 循环中。

if (isActiveBoxes[j])
{
    var boxB = sortedBoxes[j].Box;

    if (IntersectionOverUnion(boxA.Rect, boxB.Rect) > threshold)
    {
        isActiveBoxes[j] = false;
        activeCount--;

        if (activeCount <= 0)
            break;
    }
}

在检查相邻边界框的最内部 for 循环之外,查看是否还有任何剩余的边界框需要处理。 如果不是,则从外部 for-loop 中解脱出来。

if (activeCount <= 0)
    break;

最后,在方法的最初的 for 循环之外,返回结果:

return results;

太好了! 现在是时候将此代码与模型一起使用进行评分了。

使用模型进行评分

就像后期处理一样,评分步骤也包含几个步骤。 为帮助解决此问题,请将包含评分逻辑的类添加到项目。

  1. 解决方案资源管理器中,右键单击该项目,然后选择“ 添加新>”。

  2. 在“ 添加新项 ”对话框中,选择“ ”并将“ 名称 ”字段更改为 OnnxModelScorer.cs。 然后选择“添加”。

    OnnxModelScorer.cs文件将在代码编辑器中打开。 将以下 using 指令添加到 OnnxModelScorer.cs顶部:

    using System;
    using System.Collections.Generic;
    using System.Linq;
    using Microsoft.ML;
    using Microsoft.ML.Data;
    using ObjectDetection.DataStructures;
    using ObjectDetection.YoloParser;
    

    OnnxModelScorer 类定义中,添加以下变量。

    private readonly string imagesFolder;
    private readonly string modelLocation;
    private readonly MLContext mlContext;
    
    private IList<YoloBoundingBox> _boundingBoxes = new List<YoloBoundingBox>();
    

    就在下面,为 OnnxModelScorer 类创建构造函数,以初始化先前定义的变量。

    public OnnxModelScorer(string imagesFolder, string modelLocation, MLContext mlContext)
    {
        this.imagesFolder = imagesFolder;
        this.modelLocation = modelLocation;
        this.mlContext = mlContext;
    }
    

    创建构造函数后,定义几个结构,其中包含与图像和模型设置相关的变量。 创建一个称为 ImageNetSettings 包含模型输入所需的高度和宽度的结构。

    public struct ImageNetSettings
    {
        public const int imageHeight = 416;
        public const int imageWidth = 416;
    }
    

    之后,创建另一个名为 TinyYoloModelSettings 包含模型输入和输出层的名称的结构。 若要可视化模型的输入和输出层的名称,可以使用 Netron 等工具。

    public struct TinyYoloModelSettings
    {
        // for checking Tiny yolo2 Model input and  output  parameter names,
        //you can use tools like Netron, 
        // which is installed by Visual Studio AI Tools
    
        // input tensor name
        public const string ModelInput = "image";
    
        // output tensor name
        public const string ModelOutput = "grid";
    }
    

    接下来,创建用于评分的第一组方法。 在 LoadModel 类中创建 OnnxModelScorer 方法。

    private ITransformer LoadModel(string modelLocation)
    {
    
    }
    

    LoadModel 方法中,添加以下代码进行日志记录。

    Console.WriteLine("Read model");
    Console.WriteLine($"Model location: {modelLocation}");
    Console.WriteLine($"Default parameters: image size=({ImageNetSettings.imageWidth},{ImageNetSettings.imageHeight})");
    

    在调用 Fit 方法时,ML.NET 管道需要知道要操作的数据架构。 在这种情况下,将使用类似于训练的过程。 但是,由于没有实际训练发生,所以使用空 IDataView是可以接受的。 从空列表中为管道创建新的 IDataView

    var data = mlContext.Data.LoadFromEnumerable(new List<ImageNetData>());
    

    在其下方,定义管道。 管道将包含四个转换。

    • LoadImages 将图像加载为位图。
    • ResizeImages 将图像重新缩放为指定的大小(在本例中为 416 x 416)。
    • ExtractPixels 将图像的像素表示形式从位图更改为数字向量。
    • ApplyOnnxModel 加载 ONNX 模型,并使用它对提供的数据进行评分。

    在变量下面的LoadModel方法中data定义管道。

    var pipeline = mlContext.Transforms.LoadImages(outputColumnName: "image", imageFolder: "", inputColumnName: nameof(ImageNetData.ImagePath))
                    .Append(mlContext.Transforms.ResizeImages(outputColumnName: "image", imageWidth: ImageNetSettings.imageWidth, imageHeight: ImageNetSettings.imageHeight, inputColumnName: "image"))
                    .Append(mlContext.Transforms.ExtractPixels(outputColumnName: "image"))
                    .Append(mlContext.Transforms.ApplyOnnxModel(modelFile: modelLocation, outputColumnNames: new[] { TinyYoloModelSettings.ModelOutput }, inputColumnNames: new[] { TinyYoloModelSettings.ModelInput }));
    

    现在是时候实例化模型进行评分了。 在 Fit 管道上调用该方法,并返回该方法以供进一步处理。

    var model = pipeline.Fit(data);
    
    return model;
    

加载模型后,就可以使用它进行预测。 为了方便此过程,请创建一个方法,PredictDataUsingModel该方法下面调用LoadModel

private IEnumerable<float[]> PredictDataUsingModel(IDataView testData, ITransformer model)
{

}

在 中 PredictDataUsingModel,添加以下代码进行日志记录。

Console.WriteLine($"Images location: {imagesFolder}");
Console.WriteLine("");
Console.WriteLine("=====Identify the objects in the images=====");
Console.WriteLine("");

然后,使用 Transform 该方法对数据进行评分。

IDataView scoredData = model.Transform(testData);

提取预测概率,并返回这些概率以供其他处理。

IEnumerable<float[]> probabilities = scoredData.GetColumn<float[]>(TinyYoloModelSettings.ModelOutput);

return probabilities;

设置这两个步骤后,将它们合并为单个方法。 在方法下方,添加一PredictDataUsingModel个名为 Score 的新方法。

public IEnumerable<float[]> Score(IDataView data)
{
    var model = LoadModel(modelLocation);

    return PredictDataUsingModel(data, model);
}

快到了! 现在是时候把它全部使用了。

检测物体

完成所有设置后,可以检测某些对象。

评分和解析模型输出

mlContext 创建变量后,添加 try-catch 语句。

try
{

}
catch (Exception ex)
{
    Console.WriteLine(ex.ToString());
}

try 块内部,开始实现对象检测逻辑。 首先,将数据加载到一个 IDataView.

IEnumerable<ImageNetData> images = ImageNetData.ReadFromFile(imagesFolder);
IDataView imageDataView = mlContext.Data.LoadFromEnumerable(images);

然后,创建一个实例 OnnxModelScorer 并将其用于对加载的数据进行评分。

// Create instance of model scorer
var modelScorer = new OnnxModelScorer(imagesFolder, modelFilePath, mlContext);

// Use model to score data
IEnumerable<float[]> probabilities = modelScorer.Score(imageDataView);

现在是时候执行后期处理步骤了。 创建一个实例 YoloOutputParser 并将其用于处理模型输出。

YoloOutputParser parser = new YoloOutputParser();

var boundingBoxes =
    probabilities
    .Select(probability => parser.ParseOutputs(probability))
    .Select(boxes => parser.FilterBoundingBoxes(boxes, 5, .5F));

处理模型输出后,接下来是绘制图像上的边界框。

将预测结果可视化

在模型对图像进行评分并对输出进行处理后,必须在图像上绘制边界框。 为此,请在DrawBoundingBox内添加在方法下方GetAbsolutePath调用的方法。

void DrawBoundingBox(string inputImageLocation, string outputImageLocation, string imageName, IList<YoloBoundingBox> filteredBoundingBoxes)
{

}

首先,加载图像并在方法中 DrawBoundingBox 获取高度和宽度尺寸。

Image image = Image.FromFile(Path.Combine(inputImageLocation, imageName));

var originalImageHeight = image.Height;
var originalImageWidth = image.Width;

然后,创建一个 for-each 循环,用于遍历模型检测到的每个边界框。

foreach (var box in filteredBoundingBoxes)
{

}

在 for-each 循环中,获取边界框的尺寸。

var x = (uint)Math.Max(box.Dimensions.X, 0);
var y = (uint)Math.Max(box.Dimensions.Y, 0);
var width = (uint)Math.Min(originalImageWidth - x, box.Dimensions.Width);
var height = (uint)Math.Min(originalImageHeight - y, box.Dimensions.Height);

由于边界框的尺寸对应于模型输入 416 x 416,因此缩放边界框尺寸以匹配图像的实际大小。

x = (uint)originalImageWidth * x / OnnxModelScorer.ImageNetSettings.imageWidth;
y = (uint)originalImageHeight * y / OnnxModelScorer.ImageNetSettings.imageHeight;
width = (uint)originalImageWidth * width / OnnxModelScorer.ImageNetSettings.imageWidth;
height = (uint)originalImageHeight * height / OnnxModelScorer.ImageNetSettings.imageHeight;

然后,为出现在每个边界框上方的文本定义模板。 文本将包含相应边界框内对象的类以及置信度。

string text = $"{box.Label} ({(box.Confidence * 100).ToString("0")}%)";

若要在图像上绘制,请将其转换为对象 Graphics

using (Graphics thumbnailGraphic = Graphics.FromImage(image))
{

}

在代码块中 using ,优化图形 Graphics 的对象设置。

thumbnailGraphic.CompositingQuality = CompositingQuality.HighQuality;
thumbnailGraphic.SmoothingMode = SmoothingMode.HighQuality;
thumbnailGraphic.InterpolationMode = InterpolationMode.HighQualityBicubic;

在下面,设置文本和边界框的字体和颜色选项。

// Define Text Options
Font drawFont = new Font("Arial", 12, FontStyle.Bold);
SizeF size = thumbnailGraphic.MeasureString(text, drawFont);
SolidBrush fontBrush = new SolidBrush(Color.Black);
Point atPoint = new Point((int)x, (int)y - (int)size.Height - 1);

// Define BoundingBox options
Pen pen = new Pen(box.BoxColor, 3.2f);
SolidBrush colorBrush = new SolidBrush(box.BoxColor);

使用 FillRectangle 方法创建并填充一个位于边界框上方的矩形,以容纳文本。 这有助于对比文本并提高可读性。

thumbnailGraphic.FillRectangle(colorBrush, (int)x, (int)(y - size.Height - 1), (int)size.Width, (int)size.Height);

然后,使用 DrawStringDrawRectangle 方法在图像上绘制文本和边界框。

thumbnailGraphic.DrawString(text, drawFont, fontBrush, atPoint);

// Draw bounding box on image
thumbnailGraphic.DrawRectangle(pen, x, y, width, height);

在 for-each 循环之外,添加代码以在 <a0/> 中保存图像。

if (!Directory.Exists(outputImageLocation))
{
    Directory.CreateDirectory(outputImageLocation);
}

image.Save(Path.Combine(outputImageLocation, imageName));

有关应用程序在运行时按预期进行预测的其他反馈,请在LogDetectedObjects文件中添加一个调用DrawBoundingBox的方法,将检测到的对象输出到控制台。

void LogDetectedObjects(string imageName, IList<YoloBoundingBox> boundingBoxes)
{
    Console.WriteLine($".....The objects in the image {imageName} are detected as below....");

    foreach (var box in boundingBoxes)
    {
        Console.WriteLine($"{box.Label} and its Confidence score: {box.Confidence}");
    }

    Console.WriteLine("");
}

现在你有了用于从预测创建视觉反馈的辅助方法,请添加一个 for-loop 来迭代访问每个已评分图像。

for (var i = 0; i < images.Count(); i++)
{

}

在 for 循环内,获取图像文件的名称及其相关的边界框信息。

string imageFileName = images.ElementAt(i).Label;
IList<YoloBoundingBox> detectedObjects = boundingBoxes.ElementAt(i);

下面,使用 DrawBoundingBox 该方法在图像上绘制边界框。

DrawBoundingBox(imagesFolder, outputFolder, imageFileName, detectedObjects);

最后,使用 LogDetectedObjects 该方法将预测输出到控制台。

LogDetectedObjects(imageFileName, detectedObjects);

try-catch 语句后,添加其他逻辑以指示进程已完成运行。

Console.WriteLine("========= End of Process..Hit any Key ========");

就是这样!

Results

执行上述步骤后,运行控制台应用(Ctrl + F5)。 结果应类似于以下输出。 你可能会看到警告或处理消息,但为了清楚起见,这些消息已从以下结果中删除。

=====Identify the objects in the images=====

.....The objects in the image image1.jpg are detected as below....
car and its Confidence score: 0.9697262
car and its Confidence score: 0.6674225
person and its Confidence score: 0.5226039
car and its Confidence score: 0.5224892
car and its Confidence score: 0.4675332

.....The objects in the image image2.jpg are detected as below....
cat and its Confidence score: 0.6461141
cat and its Confidence score: 0.6400049

.....The objects in the image image3.jpg are detected as below....
chair and its Confidence score: 0.840578
chair and its Confidence score: 0.796363
diningtable and its Confidence score: 0.6056048
diningtable and its Confidence score: 0.3737402

.....The objects in the image image4.jpg are detected as below....
dog and its Confidence score: 0.7608147
person and its Confidence score: 0.6321323
dog and its Confidence score: 0.5967442
person and its Confidence score: 0.5730394
person and its Confidence score: 0.5551759

========= End of Process..Hit any Key ========

若要查看带有边界框的图像,请导航到 assets/images/output/ 目录。 下面是其中一个已处理图像的示例。

餐厅的示例处理图像

祝贺! 现在,通过在 ML.NET 中重用预先训练的模型,成功生成了用于对象检测的 ONNX 机器学习模型。

可以在 dotnet/machinelearning-samples 存储库中找到本教程的源代码。

在本教程中,你将学习到如何:

  • 了解问题
  • 了解 ONNX 是什么以及它如何使用 ML.NET
  • 了解模型
  • 重复使用预先训练的模型
  • 使用加载的模型检测对象

查看机器学习示例 GitHub 存储库,浏览扩展的对象检测示例。