教學課程:在 ML.NET 中使用 ONNX 偵測物件

了解如何使用 ML.NET 中預先定型的 ONNX 模型來偵測影像中物件。

若要從頭開始定型物件偵測模型,將會需要設定數以百萬計的參數、大量的標籤定型資料,以及大量的計算資源 (數以百計的 GPU 小時)。 使用預先定型的模型可讓您快速地進行定型過程。

在本教學課程中,您會了解如何:

  • 了解問題
  • 了解 ONNX 是什麼,以及它和 ML.NET 搭配運作的方式
  • 了解模型
  • 重複使用預先定型的模型
  • 使用載入的模型偵測物件

必要條件

ONNX 物件偵測範例概觀

此範例會建立 .NET Core 主控台應用程式,使用預先定型的深度學習 ONNX 模型偵測影像內的物件。 您可以在 GitHub 的 dotnet/machinelearning-samples repository 找到此範例的程式碼。

什麼是物件偵測?

物件偵測是一種電腦視覺問題。 雖然與影像分類密切相關,但物件偵測會更仔細的執行影像分類。 物件偵測會尋找影像中實體的位置「並」進行分類。 物件偵測模型通常會使用深度學習和神經網路來定型。 如需詳細資訊,請參閱 深度學習與機器學習

當影像包含不同類型的多個物件時,請使用物件偵測。

Screenshots showing Image Classification versus Object Classification.

物件偵測的一些使用案例包括:

  • 自動駕駛車輛
  • 機器人
  • 臉部偵測
  • 工作場所安全
  • 物件計數
  • 活動辨識

選取深度學習模型

深度學習是機器學習的子集。 若要定型機器學習模型,將需要大量的資料。 資料中的模式會以一系列層來表示。 資料中關聯性會編碼成層之間的連線,其中每個層都會包含權數。 權數越高,關聯性越強。 這一系列的層和連線統稱為人工神經網路。 網路中的層越多,其「深度」也越高,使其形成深度神經網路。

目前有許多不同的神經網路類型,其中最常見的便是多層感知器 (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 等工具進行視覺化。 檢查模型之後,您可以觀察到所有組成神經網路層之間的連線對應,其中每個層將包含層名稱及個別輸入/輸出的維度。 用來描述模型輸入和輸出的資料結構則稱為 Tensor。 您可以把 Tensor 想成是將資料儲存在 N 個維度中的容器。 以 Tiny YOLOv2 為例,輸入層的名稱為 image,且它會預期維度為 3 x 416 x 416 的 Tensor。 輸出層的名稱則為 grid,它會產生維度為 125 x 13 x 13 的輸出 Tensor。

Input layer being split into hidden layers, then output layer

YOLO 模型接受 3(RGB) x 416px x 416px 的影像。 模型會接受此輸入,並透過不同的層傳遞它來產生輸出。 輸出會將輸入影像分成 13 x 13 的格線,格線中的每個儲存格都由 125 個值組成。

什麼是 ONNX 模型?

Open Neural Network Exchange (ONNX) 是一種 AI 型的開放原始碼格式。 ONNX 支援架構間的互通性。 這表示您可以在其中一個熱門的機器學習架構 (例如 PyTorch) 中定型模型、將它轉換成 ONNX 格式,然後在 ML.NET 等不同的架構中取用 ONNX 模型。 若要深入了解,請前往 ONNX 網站

Diagram of ONNX supported formats being used.

預先定型的 Tiny YOLOv2 模型是以 ONNX 格式儲存,它是一種層的序列化表示和從這些層中所學習到模式。 在 ML.NET 中,與 ONNX 的互通性是透過 ImageAnalyticsOnnxTransformer NuGet 套件來達成。 ImageAnalytics 套件包含一系列的轉換,這些轉換會接受影像,並將影像編碼成可用來作為輸入以進行預測或定型管線的數值。 OnnxTransformer 套件會利用 ONNX 執行階段載入 ONNX 模型,並用它來根據所提供的輸入進行預測。

Data flow of ONNX file into the ONNX Runtime.

設定 .NET 主控台專案

現在您已對 ONNX 以及 Tiny YOLOv2 的運作方式有了一般程度的了解,是時候建置應用程式了。

建立主控台應用程式

  1. 建立名為 「ObjectDetection」 的 C# 主控台應用程式 。 按一下 [下一步] 按鈕。

  2. 選擇 [.NET 6] 作為要使用的架構。 按一下 [建立] 按鈕。

  3. 安裝 Microsoft.ML NuGet 套件:

    注意

    除非另有說明,否則此範例會使用所提及 NuGet 套件的最新穩定版本。

    • 在 [方案總管] 中,於您的專案上按一下滑鼠右鍵,然後選取 [管理 NuGet 套件]
    • 選擇 [nuget.org] 作為 [套件來源],選取 [瀏覽] 索引標籤,搜尋 Microsoft.ML
    • 選取 [安裝] 按鈕。
    • 在 [預覽變更] 對話方塊上,選取 [確定] 按鈕,然後在 [授權接受] 對話方塊上,如果您同意所列套件的授權條款,請選取 [我接受]
    • 針對 Microsoft.Windows.Compatibility 、Microsoft.ML.ImageAnalytics Microsoft.ML.OnnxTransformer Microsoft.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. 首先,在 Program.cs 檔案底部 建立 GetAbsolutePath 方法。

    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藉由在 欄位下方 outputFolder 新增下列這一行,以 使用 的新實例 MLContext 初始化 變數。

MLContext mlContext = new MLContext();

建立剖析器來針對模型輸出進行後續處理

模型會將影像分成 13 x 13 的格線,其中每個格線儲存格都是 32px x 32px。 每個格線儲存格都包含 5 個可能的物件週框方塊。 週框方塊包含 25 個項目:

Grid sample on the left, and Bounding Box sample on the right

  • x 週框方塊中心的 X 位置,相對於與其建立關聯的格線儲存格。
  • y 週框方塊中心的 Y 位置,相對於與其建立關聯的格線儲存格。
  • w 週框方塊的寬度。
  • h 週框方塊的高度。
  • o 物件存在於週框方塊內部的信賴度,也稱為物件性分數。
  • p1-p20 為模型所預測 20 個類別各自的類別機率。

25 個描述 5 個週框方塊的項目會構成每個格線儲存格中所包含 125 個項目。

預先定型 ONNX 模型所產生輸出是長度為 21125 的 float 陣列,代表維度為 125 x 13 x 13 Tensor 的項目。 為了將模型所產生的預測轉換成 Tensor,將需要進行一些後續處理工作。 若要執行此操作,請建立一組類別來協助剖析輸出。

將新目錄新增到您的專案來整理剖析器類別組。

  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 類別定義中,新增包含影像中每個儲存格維度的巢狀類別。 為 CellDimensions 繼承自 DimensionsBase 類別定義頂端 YoloOutputParser 類別的 類別新增下列程式碼。

    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 是格線中目前儲存格的開始位置。

    當模型進行預測 (也稱為評分) 時,它會將 416px x 416px 輸入影像分成大小為 13 x 13 的儲存格格線。 每個儲存格都包含 32px x 32px。 在每個儲存格中都有 5 個週框方塊,每個週框方塊都包含了 5 個特徵 (X、Y、高度、寬度、信賴度)。 此外,每個周框方塊都包含每個類別的機率,在此案例中為 20。 因此,每個儲存格都包含 125 個資訊 (5 個特徵 + 20 個類別機率)。

channelStride 的下方為 5 個週框方塊建立錨點清單:

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 會套用 sigmoid 函式,輸出介於 0 和 1 之間的數字。
  • Softmax 會將輸入向量正常化為機率分佈。
  • GetOffset 會將單一維度模型輸出中項目對應到 125 x 13 x 13 Tensor 中相對應的位置。
  • 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 清單。 將下列 return 陳述式新增到 ParseOutputs 方法最外層 for 迴圈的下方。

return boxes;

篩選重疊方塊

現在您已從模型輸出擷取所有信賴度較高的週框方塊,您仍需要進行額外篩選才能移除重疊的影像。 在 ParseOutputs 方法下方新增稱為 FilterBoundingBoxes 的方法:

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 陳述式中新增下列程式碼。

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

if (results.Count >= limit)
    break;

否則,請查看相鄰的週框方塊。 在方塊數限制檢查的下方新增下列程式碼。

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

}

與第一個方塊相似,若相鄰的方塊已為使用中或是準備好進行處理,請使用 IntersectionOverUnion 方法來檢查第一個方塊和第二個方塊是否超過指定的閾值。 將下列程式碼新增至最內部的 for-loop。

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 迴圈。

if (activeCount <= 0)
    break;

最後,在 FilterBoundingBoxes 方法初始 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";
    }
    

    接下來,請建立用來評分的第一組方法。 在您的 OnnxModelScorer 類別中建立 LoadModel 方法。

    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})");
    

    ML.NET 管線必須知道呼叫 Fit 方法時於其上運作所的資料結構描述。 在此案例中,我們會使用與定型相似的處理過程。 但是,由於實際上不會進行定型,因此我們也可以使用空白的 IDataView。 從空白清單為管線建立新的 IDataView

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

    在其下方定義管線。 管線會由四個轉換組成。

    • LoadImages 會將影像作為點陣圖載入。
    • ResizeImages 會將影像的大小重新調整為指定大小 (在此案例中為 416 x 416)。
    • ExtractPixels 會將影像的像素表示法從點陣圖變更為數值向量。
    • ApplyOnnxModel 會載入 ONNX 模型並用它來對提供的資料進行評分。

    data 變數下方的 LoadModel 方法中定義您的管線。

    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;
    

載入模型後,便可以用它來進行預測。 若要輔助該程序,請在 LoadModel 方法的下方建立稱為 PredictDataUsingModel 的方法。

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));

處理完模型輸出後,是時候在影像上繪製週框方塊了。

將預測視覺化

在模型對影像進行評分且輸出處理完畢後,必須在影像上繪製週框方塊。 若要執行此操作,請在 Program.csGetAbsolutePath 方法下方新增稱為 DrawBoundingBox 的方法。

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 迴圈的外部,新增程式碼來在 outputFolder 中儲存影像。

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

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

如需應用程式在執行時間如預期進行預測的其他意見反應,請在 Program.cs 檔案中的 方法下方 DrawBoundingBox 新增名為 LogDetectedObjects 的方法,將偵測到的物件輸出至主控台。

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("");
}

現在您有 Helper 方法可從預測建立視覺化回饋,請新增 for 迴圈來逐一查看每個經過評分的影像。

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 ========");

介紹完畢

結果

完成上述步驟後,請執行主控台應用程式 (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/ 目錄。 以下是其中一個經處理影像的範例。

Sample processed image of a dining room

恭喜! 您已透過在 ML.NET 中重複使用已預先定型的 ONNX 模型,成功建置出可用來偵測物件的機器學習模型。

您可以在 dotnet/machinelearning-samples 存放庫找到本教學 課程的原始程式碼。

在本教學課程中,您已了解如何:

  • 了解問題
  • 了解 ONNX 是什麼,以及它和 ML.NET 搭配運作的方式
  • 了解模型
  • 重複使用預先定型的模型
  • 使用載入的模型偵測物件

查看機器學習範例 GitHub 存放庫,探索更複雜的物件偵測範例。