共用方式為


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

學習如何在 ML.NET 中使用預訓練的 ONNX 模型來偵測影像中的物體。

從零開始訓練物件偵測模型需要設定數百萬個參數、大量標註訓練資料,以及龐大的運算資源(數百 GPU 小時)。 使用預訓練模型可以讓你縮短訓練流程。

在本教學課程中,您將瞭解如何:

  • 了解問題
  • 了解什麼是 ONNX,以及它如何與 ML.NET 互動
  • 了解模型
  • 重複使用預訓練模型
  • 用載入模型偵測物件

先決條件

ONNX 物體偵測範例概述

本範例建立了一個 .NET 核心主控台應用程式,利用預訓練的深度學習 ONNX 模型偵測影像中的物件。 這個範例的程式碼可以在 GitHub 上的 dotnet/machinelearning-samples 倉庫 找到。

什麼是物體偵測?

物體偵測是一種電腦視覺問題。 雖然與影像分類密切相關,物體偵測在更細緻的尺度上進行影像分類。 物體偵測同時定位 分類影像中的實體。 物體偵測模型通常利用深度學習與神經網路進行訓練。 更多資訊請參見 深度學習與機器學習

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

圖片分類與物件分類的截圖。

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

  • 自動駕駛車輛
  • 機器人學
  • 臉部偵測
  • 職場安全
  • 物件計數
  • 活動識別

選擇深度學習模型

深度學習是機器學習的一個子集。 訓練深度學習模型需要大量資料。 資料中的模式由一系列層次表示。 資料中的關係被編碼為包含權重的層之間的連結。 體重越高,關係就越牢固。 這一系列層次與連結統稱為人工神經網路。 網絡層越多,它就越「深」,成為一個深度神經網路。

神經網路有不同類型,最常見的有多層感知器(MLP)、卷積神經網路(CNN)和循環神經網路(RNN)。 最基本的是MLP,它將一組輸入映射到一組輸出。 當資料沒有空間或時間成分時,這種神經網路是有效的。 CNN 利用卷積層來處理資料中包含的空間資訊。 CNN的一個良好應用是影像處理,用以偵測影像區域中是否有特徵(例如,影像中心是否有鼻子?)。 最後,RNN 允許將狀態或記憶體的持久性用於輸入。 RNN 用於時間序列分析,事件的順序與上下文非常重要。

了解模型

物體偵測是一項影像處理任務。 因此,大多數訓練用來解決此問題的深度學習模型都是卷積神經網絡(CNN)。 本教學中使用的模型是 Tiny YOLOv2 模型,這是 Redmon 和 Farhadi 所著論文《YOLO9000: Better, Faster, Stronger》中描述的 YOLOv2 模型的更精簡版本。 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)是一種開源的人工智慧模型格式。 ONNX 支援框架間的互通性。 這表示你可以在許多熱門的機器學習框架(如 PyTorch)中訓練模型,將其轉換成 ONNX 格式,然後在像 ML.NET 這樣的其他框架中使用 ONNX 模型。 欲了解更多,請造訪 ONNX官方網站

ONNX 支援格式的示意圖。

預訓練的 Tiny YOLOv2 模型以 ONNX 格式儲存,這是圖層及所學模式的序列化表示。 在 ML.NET 中,透過 ImageAnalyticsOnnxTransformer NuGet 套件實現與 ONNX 的互通性。 該 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. 下載 Project 資產目錄的壓縮檔 並解壓。

  2. 將目錄 assets 複製到你的 ObjectDetection 專案目錄中。 這個目錄及其子目錄包含了本教學所需的影像檔(除了 Tiny YOLOv2 模型,你將在下一步下載並新增)。

  3. ONNX Model Zoo 下載 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. 首先,在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頂部加上以下指令:

    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頂部加上以下指令:

    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

outputFolder欄位下方加上以下行,以用新的MLContext實例初始化mlContext變數。

MLContext mlContext = new MLContext();

建立解析器來後處理模型輸出

模型將影像分割成一個 13 x 13 網格,每個網格為 32px x 32px。 每個格子包含 5 個潛在的物件邊界框。 一個包圍盒包含 25 個元素:

左邊是網格樣本,右邊是邊界框樣本

  • x 包圍框中心相對於其關聯格子的 x 位置。
  • y 也就是包圍盒中心相對於它所關聯格點單元的 y 位置。
  • w 邊界框的寬度。
  • h 邊界框的高度。
  • o 物體存在於包圍框內的信心值,也稱為物體性分數。
  • p1-p20 模型預測的20個分類的類別機率。

總計,描述每個 5 個邊界框的 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頂部加上以下指令:

    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頂部新增以下指令:

    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 是當前格子在網格中的起始位置。

    當模型做出預測(也稱為評分)時,會將 416px x 416px 輸入影像分割成大小為 13 x 13的格子網格。 每個儲存格包含 。32px x 32px 每個格子內有 5 個邊界框,每個框包含 5 個特徵(x、y、寬度、高度、置信度)。 此外,每個包圍框包含每個類別的機率,在此例中為 20。 因此,每個儲存格包含125個資訊(5個特徵+20個類別機率)。

以下是所有五個邊界框在 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 套用 S形體函數,輸出介於 0 到 1 之間的數字。
  • 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 迴圈下方加入以下的 return 陳述。

return boxes;

濾波器重疊盒

現在,所有具有高置信度的邊界框都已從模型輸出中提取出來,接下來需要進行額外的過濾,以移除重疊的框。 請加入一個稱為 FilterBoundingBoxesParseOutputs 方法的方法:

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 迴圈之外,查看是否有任何剩餘的邊界框需要處理。 如果沒有,就跳出外圈。

if (activeCount <= 0)
    break;

最後,在方法初始的 for 迴圈 FilterBoundingBoxes 之外,回傳結果:

return results;

太棒了! 現在是時候用這段程式碼和模型來評分了。

使用模型來評分

就像後製一樣,評分過程有幾個步驟。 為了幫助你,可以新增一個類別,將計分邏輯納入你的專案中。

  1. 解決方案總管中,右鍵點擊專案,然後選擇 新增>項目

  2. 新增項目 對話框中,選擇 類別 並將 名稱 欄位改為 OnnxModelScorer.cs。 然後,選取 新增

    OnnxModelScorer.cs檔案會在程式碼編輯器中開啟。 請在using頂部新增以下指示:

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

    ML.NET 管線需要在呼叫 Fit 方法時,知道要操作的資料模式。 此時將採用類似訓練的流程。 然而,由於沒有實際訓練,使用空 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)
{

}

在 foreach 迴圈內,取得包圍框的尺寸。

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

接著,用 DrawString and DrawRectangle 方法在圖片上畫出文字和邊界框。

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

為了獲得應用程式執行時預測的額外回饋,可以在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/ 目錄。 以下是其中一張處理過的影像範例。

飯廳取樣處理過的影像

祝賀! 你現在成功地透過重用預訓練 ONNX 模型在 ML.NET 中建立了一個物體偵測的機器學習模型。

你可以在 dotnet/machinelearning-samples 資料庫找到這個教學的原始碼。

在本教程中,您將學到如何:

  • 了解問題
  • 了解什麼是 ONNX,以及它如何與 ML.NET 互動
  • 了解模型
  • 重複使用預訓練模型
  • 用載入模型偵測物件

請參考 Machine Learning 範例 GitHub 倉庫,探索擴展的物件偵測範例。