Tutorial: Detetar objetos usando ONNX no ML.NET

Saiba como usar um modelo ONNX pré-treinado no ML.NET para detetar objetos em imagens.

Treinar um modelo de deteção de objetos do zero requer a definição de milhões de parâmetros, uma grande quantidade de dados de treinamento rotulados e uma vasta quantidade de recursos de computação (centenas de horas de GPU). O uso de um modelo pré-treinado permite que você atalho o processo de treinamento.

Neste tutorial, irá aprender a:

  • Entenda o problema
  • Saiba o que é ONNX e como funciona com ML.NET
  • Entenda o modelo
  • Reutilizar o modelo pré-treinado
  • Detetar objetos com um modelo carregado

Pré-requisitos

Visão geral do exemplo de deteção de objeto ONNX

Este exemplo cria um aplicativo de console principal do .NET que deteta objetos dentro de uma imagem usando um modelo ONNX de aprendizado profundo pré-treinado. O código para este exemplo pode ser encontrado no repositório dotnet/machinelearning-samples no GitHub.

O que é deteção de objetos?

A deteção de objetos é um problema de visão computacional. Embora esteja intimamente relacionada à classificação de imagens, a deteção de objetos executa a classificação de imagens em uma escala mais granular. A deteção de objetos localiza e categoriza entidades dentro de imagens. Os modelos de deteção de objetos são comumente treinados usando aprendizagem profunda e redes neurais. Consulte Deep learning vs machine learning para obter mais informações.

Use a deteção de objetos quando as imagens contiverem vários objetos de tipos diferentes.

Screenshots showing Image Classification versus Object Classification.

Alguns casos de uso para deteção de objetos incluem:

  • Carros autônomos
  • Robótica
  • Deteção de Rostos
  • Segurança no Trabalho
  • Contagem de objetos
  • Reconhecimento de Atividades

Selecione um modelo de aprendizagem profunda

A aprendizagem profunda é um subconjunto da machine learning. Para treinar modelos de aprendizagem profunda, são necessárias grandes quantidades de dados. Os padrões nos dados são representados por uma série de camadas. As relações nos dados são codificadas como conexões entre as camadas que contêm pesos. Quanto maior o peso, mais forte a relação. Coletivamente, essa série de camadas e conexões são conhecidas como redes neurais artificiais. Quanto mais camadas em uma rede, mais "profunda" ela é, tornando-a uma rede neural profunda.

Existem diferentes tipos de redes neurais, sendo as mais comuns a Perceptron Multicamadas (MLP), a Rede Neural Convolucional (CNN) e a Rede Neural Recorrente (RNN). O mais básico é o MLP, que mapeia um conjunto de entradas para um conjunto de saídas. Esta rede neural é boa quando os dados não têm um componente espacial ou temporal. A CNN faz uso de camadas convolucionais para processar informações espaciais contidas nos dados. Um bom caso de uso para CNNs é o processamento de imagem para detetar a presença de um recurso em uma região de uma imagem (por exemplo, há um nariz no centro de uma imagem?). Finalmente, os RNNs permitem que a persistência do estado ou da memória seja usada como entrada. Os RNNs são usados para análise de séries temporais, onde a ordenação sequencial e o contexto dos eventos são importantes.

Entenda o modelo

A deteção de objetos é uma tarefa de processamento de imagem. Portanto, a maioria dos modelos de deep learning treinados para resolver esse problema são CNNs. O modelo usado neste tutorial é o modelo Tiny YOLOv2, uma versão mais compacta do modelo YOLOv2 descrito no artigo: "YOLO9000: Better, Faster, Stronger" de Redmon e Farhadi. O Tiny YOLOv2 é treinado no conjunto de dados Pascal VOC e é composto por 15 camadas que podem prever 20 classes diferentes de objetos. Como o Tiny YOLOv2 é uma versão condensada do modelo YOLOv2 original, é feita uma compensação entre velocidade e precisão. As diferentes camadas que compõem o modelo podem ser visualizadas usando ferramentas como o Netron. Inspecionar o modelo produziria um mapeamento das conexões entre todas as camadas que compõem a rede neural, onde cada camada conteria o nome da camada juntamente com as dimensões da respetiva entrada/saída. As estruturas de dados usadas para descrever as entradas e saídas do modelo são conhecidas como tensores. Os tensores podem ser pensados como contêineres que armazenam dados em N-dimensões. No caso do Tiny YOLOv2, o nome da camada de entrada é image e espera um tensor de dimensões 3 x 416 x 416. O nome da camada de saída é grid e gera um tensor de saída de dimensões 125 x 13 x 13.

Input layer being split into hidden layers, then output layer

O modelo YOLO tira uma imagem 3(RGB) x 416px x 416px. O modelo pega essa entrada e a passa pelas diferentes camadas para produzir uma saída. A saída divide a imagem de entrada em uma 13 x 13 grade, com cada célula na grade consistindo de 125 valores.

O que é um modelo ONNX?

O Open Neural Network Exchange (ONNX) é um formato de código aberto para modelos de IA. O ONNX suporta a interoperabilidade entre frameworks. Isso significa que você pode treinar um modelo em uma das muitas estruturas populares de aprendizado de máquina como o PyTorch, convertê-lo no formato ONNX e consumir o modelo ONNX em uma estrutura diferente, como ML.NET. Para saber mais, visite o site da ONNX.

Diagram of ONNX supported formats being used.

O modelo Tiny YOLOv2 pré-treinado é armazenado no formato ONNX, uma representação serializada das camadas e padrões aprendidos dessas camadas. Além ML.NET, a interoperabilidade com o ONNX é alcançada com os ImageAnalytics pacotes NuGet e OnnxTransformer NuGet. O ImageAnalytics pacote contém uma série de transformações que pegam uma imagem e a codificam em valores numéricos que podem ser usados como entrada em um pipeline de previsão ou treinamento. O OnnxTransformer pacote aproveita o ONNX Runtime para carregar um modelo ONNX e usá-lo para fazer previsões com base na entrada fornecida.

Data flow of ONNX file into the ONNX Runtime.

Configurar o projeto .NET Console

Agora que você tem uma compreensão geral do que é ONNX e como o Tiny YOLOv2 funciona, é hora de criar o aplicativo.

Criar uma aplicação de consola

  1. Crie um aplicativo de console em C# chamado "ObjectDetection". Clique no botão Seguinte.

  2. Escolha .NET 6 como a estrutura a ser usada. Clique no botão Criar.

  3. Instale o Microsoft.ML pacote NuGet:

    Nota

    Este exemplo usa a versão estável mais recente dos pacotes NuGet mencionados, salvo indicação em contrário.

    • No Gerenciador de Soluções, clique com o botão direito do mouse em seu projeto e selecione Gerenciar Pacotes NuGet.
    • Escolha "nuget.org" como a origem do pacote, selecione a guia Procurar, procure por Microsoft.ML.
    • Selecione o botão Instalar .
    • Selecione o botão OK na caixa de diálogo Visualizar alterações e, em seguida, selecione o botão Aceito na caixa de diálogo Aceitação de licença se concordar com os termos de licença para os pacotes listados.
    • Repita estas etapas para Microsoft.Windows.Compatibility, Microsoft.ML.ImageAnalytics, Microsoft.ML.OnnxTransformer e Microsoft.ML.OnnxRuntime.

Prepare seus dados e modelo pré-treinado

  1. Baixe o arquivo zip e descompacte o diretório de ativos do projeto.

  2. Copie o diretório para o diretório do assets projeto ObjectDetection. Este diretório e seus subdiretórios contêm os arquivos de imagem (exceto para o modelo Tiny YOLOv2, que você baixará e adicionará na próxima etapa) necessários para este tutorial.

  3. Faça o download do modelo Tiny YOLOv2 do ONNX Model Zoo.

  4. Copie o arquivo para o diretório do projeto assets\Model ObjectDetection e renomeie-o model.onnx para TinyYolo2_model.onnx. Este diretório contém o modelo necessário para este tutorial.

  5. No Gerenciador de Soluções, clique com o botão direito do mouse em cada um dos arquivos no diretório e subdiretórios de ativos e selecione Propriedades. Em Avançado, altere o valor de Copiar para Diretório de Saída para Copiar se for mais recente.

Criar classes e definir caminhos

Abra o arquivo Program.cs e adicione as seguintes instruções adicionais using à parte superior do arquivo:

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

Em seguida, defina os caminhos dos vários ativos.

  1. Primeiro, crie o GetAbsolutePath método na parte inferior do arquivo Program.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. Em seguida, abaixo das instruções de uso, crie campos para armazenar a localização de seus ativos.

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

Adicione um novo diretório ao seu projeto para armazenar seus dados de entrada e classes de previsão.

No Gerenciador de Soluções, clique com o botão direito do mouse no projeto e selecione Adicionar>Nova Pasta. Quando a nova pasta aparecer no Gerenciador de Soluções, nomeie-a como "DataStructures".

Crie sua classe de dados de entrada no diretório DataStructures recém-criado.

  1. No Gerenciador de Soluções, clique com o botão direito do mouse no diretório DataStructures e selecione Adicionar>Novo Item.

  2. Na caixa de diálogo Adicionar Novo Item, selecione Classe e altere o campo Nome para ImageNetData.cs. Em seguida, selecione o botão Adicionar .

    O arquivo ImageNetData.cs é aberto no editor de códigos. Adicione a seguinte using instrução à parte superior de ImageNetData.cs:

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

    Remova a definição de classe existente e adicione o seguinte código para a ImageNetData classe ao arquivo 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 é a classe de dados de imagem de entrada e tem os seguintes String campos:

    • ImagePath Contém o caminho onde a imagem está armazenada.
    • Label contém o nome do arquivo.

    Além disso, ImageNetData contém um método ReadFromFile que carrega vários arquivos de imagem armazenados no imageFolder caminho especificado e os retorna como uma coleção de ImageNetData objetos.

Crie sua classe de previsão no diretório DataStructures .

  1. No Gerenciador de Soluções, clique com o botão direito do mouse no diretório DataStructures e selecione Adicionar>Novo Item.

  2. Na caixa de diálogo Adicionar Novo Item, selecione Classe e altere o campo Nome para ImageNetPrediction.cs. Em seguida, selecione o botão Adicionar .

    O arquivo ImageNetPrediction.cs é aberto no editor de códigos. Adicione a seguinte using instrução à parte superior de ImageNetPrediction.cs:

    using Microsoft.ML.Data;
    

    Remova a definição de classe existente e adicione o seguinte código para a ImageNetPrediction classe ao arquivo ImageNetPrediction.cs :

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

    ImageNetPrediction é a classe de dados de previsão e tem o seguinte float[] campo:

    • PredictedLabels Contém as dimensões, a pontuação de objetividade e as probabilidades de classe para cada uma das caixas delimitadoras detetadas em uma imagem.

Inicializar variáveis

A classe MLContext é um ponto de partida para todas as operações ML.NET, e a mlContext inicialização cria um novo ambiente de ML.NET que pode ser compartilhado entre os objetos de fluxo de trabalho de criação de modelo. É semelhante, conceitualmente, ao DBContext Entity Framework.

Inicialize a variável com uma nova instância de MLContext adicionando a mlContext seguinte linha abaixo do outputFolder campo.

MLContext mlContext = new MLContext();

Criar um analisador para saídas de modelo pós-processo

O modelo segmenta uma imagem em uma 13 x 13 grade, onde cada célula de grade é 32px x 32px. Cada célula de grade contém 5 caixas delimitadoras de objetos potenciais. Uma caixa delimitadora tem 25 elementos:

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

  • x A posição X do centro da caixa delimitadora em relação à célula da grade à qual está associada.
  • y A posição y do centro da caixa delimitadora em relação à célula da grade à qual está associada.
  • w A largura da caixa delimitadora.
  • h a altura da caixa delimitadora.
  • o O valor de confiança de que um objeto existe dentro da caixa delimitadora, também conhecido como Pontuação de objetividade.
  • p1-p20 probabilidades de classe para cada uma das 20 classes previstas pelo modelo.

No total, os 25 elementos que descrevem cada uma das 5 caixas delimitadoras compõem os 125 elementos contidos em cada célula da grade.

A saída gerada pelo modelo ONNX pré-treinado é uma matriz flutuante de comprimento 21125, representando os elementos de um tensor com dimensões 125 x 13 x 13. Para transformar as previsões geradas pelo modelo em um tensor, é necessário algum trabalho de pós-processamento. Para fazer isso, crie um conjunto de classes para ajudar a analisar a saída.

Adicione um novo diretório ao seu projeto para organizar o conjunto de classes do analisador.

  1. No Gerenciador de Soluções, clique com o botão direito do mouse no projeto e selecione Adicionar>Nova Pasta. Quando a nova pasta aparecer no Gerenciador de Soluções, nomeie-a como "YoloParser".

Criar caixas delimitadoras e dimensões

A saída de dados pelo modelo contém coordenadas e dimensões das caixas delimitadoras de objetos dentro da imagem. Crie uma classe base para dimensões.

  1. No Gerenciador de Soluções, clique com o botão direito do mouse no diretório YoloParser e selecione Adicionar>Novo Item.

  2. Na caixa de diálogo Adicionar Novo Item, selecione Classe e altere o campo Nome para DimensionsBase.cs. Em seguida, selecione o botão Adicionar .

    O arquivo DimensionsBase.cs é aberto no editor de códigos. Remova todas as using instruções e a definição de classe existente.

    Adicione o seguinte código para a DimensionsBase classe ao arquivo 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 tem as seguintes float propriedades:

    • X Contém a posição do objeto ao longo do eixo x.
    • Y Contém a posição do objeto ao longo do eixo y.
    • Height Contém a altura do objeto.
    • Width Contém a largura do objeto.

Em seguida, crie uma classe para suas caixas delimitadoras.

  1. No Gerenciador de Soluções, clique com o botão direito do mouse no diretório YoloParser e selecione Adicionar>Novo Item.

  2. Na caixa de diálogo Adicionar Novo Item, selecione Classe e altere o campo Nome para YoloBoundingBox.cs. Em seguida, selecione o botão Adicionar .

    O arquivo YoloBoundingBox.cs é aberto no editor de códigos. Adicione a seguinte using instrução à parte superior de YoloBoundingBox.cs:

    using System.Drawing;
    

    Logo acima da definição de classe existente, adicione uma nova definição de classe chamada BoundingBoxDimensions que herda da classe para conter as dimensões da DimensionsBase respetiva caixa delimitadora.

    public class BoundingBoxDimensions : DimensionsBase { }
    

    Remova a definição de classe existente YoloBoundingBox e adicione o seguinte código para a YoloBoundingBox classe ao arquivo 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 tem as seguintes propriedades:

    • Dimensions Contém dimensões da caixa delimitadora.
    • Label Contém a classe de objeto detetada dentro da caixa delimitadora.
    • Confidence contém a confiança da classe.
    • Rect Contém a representação retangular das dimensões da caixa delimitadora.
    • BoxColor Contém a cor associada à respetiva classe usada para desenhar na imagem.

Criar o analisador

Agora que as classes para dimensões e caixas delimitadoras foram criadas, é hora de criar o analisador.

  1. No Gerenciador de Soluções, clique com o botão direito do mouse no diretório YoloParser e selecione Adicionar>Novo Item.

  2. Na caixa de diálogo Adicionar Novo Item, selecione Classe e altere o campo Nome para YoloOutputParser.cs. Em seguida, selecione o botão Adicionar .

    O arquivo YoloOutputParser.cs é aberto no editor de códigos. Adicione as seguintes using instruções ao topo do YoloOutputParser.cs:

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

    Dentro da definição de classe existente YoloOutputParser , adicione uma classe aninhada que contenha as dimensões de cada uma das células na imagem. Adicione o seguinte código para a CellDimensions classe que herda da DimensionsBase classe na parte superior da YoloOutputParser definição de classe.

    class CellDimensions : DimensionsBase { }
    
  3. Dentro da definição de YoloOutputParser classe, adicione as constantes e o campo a seguir.

    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 é o número de linhas na grelha em que a imagem está dividida.
    • COL_COUNT é o número de colunas na grelha em que a imagem está dividida.
    • CHANNEL_COUNT é o número total de valores contidos numa célula da grelha.
    • BOXES_PER_CELL é o número de caixas delimitadoras numa célula,
    • BOX_INFO_FEATURE_COUNT é o número de características contidas numa caixa (x, y, altura, largura, confiança).
    • CLASS_COUNT é o número de previsões de classe contidas em cada caixa delimitadora.
    • CELL_WIDTH é a largura de uma célula na grade da imagem.
    • CELL_HEIGHT é a altura de uma célula na grade de imagem.
    • channelStride é a posição inicial da célula atual na grade.

    Quando o modelo faz uma previsão, também conhecida como pontuação, ele divide a 416px x 416px imagem de entrada em uma grade de células do tamanho de 13 x 13. Cada célula contém é 32px x 32px. Dentro de cada célula, existem 5 caixas delimitadoras cada uma contendo 5 características (x, y, largura, altura, confiança). Além disso, cada caixa delimitadora contém a probabilidade de cada uma das classes, que neste caso é 20. Portanto, cada célula contém 125 informações (5 características + 20 probabilidades de classe).

Crie uma lista de âncoras abaixo channelStride para todas as 5 caixas delimitadoras:

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

As âncoras são relações pré-definidas de altura e largura das caixas delimitadoras. A maioria dos objetos ou classes detetados por um modelo têm proporções semelhantes. Isso é valioso quando se trata de criar caixas delimitadoras. Em vez de prever as caixas delimitadoras, o deslocamento das dimensões predefinidas é calculado, reduzindo assim o cálculo necessário para prever a caixa delimitadora. Normalmente, essas taxas de ancoragem são calculadas com base no conjunto de dados usado. Nesse caso, como o conjunto de dados é conhecido e os valores foram pré-calculados, as âncoras podem ser codificadas.

Em seguida, defina os rótulos ou classes que o modelo irá prever. Este modelo prevê 20 classes, que é um subconjunto do número total de classes previstas pelo modelo YOLOv2 original.

Adicione a sua lista de etiquetas abaixo do 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"
};

Existem cores associadas a cada uma das classes. Atribua as cores da sua classe abaixo do seu 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
};

Criar funções auxiliares

Há uma série de etapas envolvidas na fase de pós-processamento. Para ajudar com isso, vários métodos auxiliares podem ser empregados.

Os métodos auxiliares usados pelo analisador são:

  • Sigmoid Aplica a função sigmoide que produz um número entre 0 e 1.
  • Softmax normaliza um vetor de entrada em uma distribuição de probabilidade.
  • GetOffset Mapeia elementos na saída do modelo unidimensional para a posição correspondente em um 125 x 13 x 13 tensor.
  • ExtractBoundingBoxes Extrai as dimensões da caixa delimitadora usando o GetOffset método da saída do modelo.
  • GetConfidence Extrai o valor de confiança que indica a certeza de que o modelo detetou um objeto e usa a Sigmoid função para transformá-lo em uma porcentagem.
  • MapBoundingBoxToCell usa as dimensões da caixa delimitadora e as mapeia em sua respetiva célula dentro da imagem.
  • ExtractClasses Extrai as previsões de classe para a caixa delimitadora da saída do modelo usando o método e as transforma em uma distribuição de probabilidade usando o GetOffsetSoftmax método.
  • GetTopResult seleciona a classe da lista de classes previstas com a maior probabilidade.
  • IntersectionOverUnion Filtra caixas delimitadoras sobrepostas com probabilidades mais baixas.

Adicione o código para todos os métodos auxiliares abaixo da sua lista de 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);
}

Depois de definir todos os métodos auxiliares, é hora de usá-los para processar a saída do modelo.

Abaixo do IntersectionOverUnion método, crie o ParseOutputs método para processar a saída gerada pelo modelo.

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

}

Crie uma lista para armazenar suas caixas delimitadoras e definir variáveis dentro do ParseOutputs método.

var boxes = new List<YoloBoundingBox>();

Cada imagem é dividida em uma grade de 13 x 13 células. Cada célula contém cinco caixas delimitadoras. Abaixo da boxes variável, adicione código para processar todas as caixas em cada uma das células.

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++)
        {

        }
    }
}

Dentro do loop mais interno, calcule a posição inicial da caixa atual dentro da saída do modelo unidimensional.

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

Diretamente abaixo disso, use o ExtractBoundingBoxDimensions método para obter as dimensões da caixa delimitadora atual.

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

Em seguida, use o GetConfidence método para obter a confiança para a caixa delimitadora atual.

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

Depois disso, use o MapBoundingBoxToCell método para mapear a caixa delimitadora atual para a célula atual que está sendo processada.

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

Antes de fazer qualquer processamento adicional, verifique se o seu valor de confiança é maior do que o limite fornecido. Caso contrário, processe a próxima caixa delimitadora.

if (confidence < threshold)
    continue;

Caso contrário, continue processando a saída. A próxima etapa é obter a distribuição de probabilidade das classes previstas para a caixa delimitadora atual usando o ExtractClasses método.

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

Em seguida, use o método para obter o GetTopResult valor e o índice da classe com a maior probabilidade para a caixa atual e calcular sua pontuação.

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

Use o topScore para mais uma vez manter apenas as caixas delimitadoras que estão acima do limite especificado.

if (topScore < threshold)
    continue;

Finalmente, se a caixa delimitadora atual exceder o limite, crie um novo BoundingBox objeto e adicione-o boxes à lista.

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

Depois que todas as células da imagem tiverem sido processadas, retorne a boxes lista. Adicione a seguinte instrução return abaixo do outer-most for-loop no ParseOutputs método.

return boxes;

Filtrar caixas sobrepostas

Agora que todas as caixas delimitadoras altamente confiáveis foram extraídas da saída do modelo, uma filtragem adicional precisa ser feita para remover imagens sobrepostas. Adicione um método chamado FilterBoundingBoxes abaixo do ParseOutputs método:

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

}

Dentro do FilterBoundingBoxes método, comece criando uma matriz igual ao tamanho das caixas detetadas e marcando todos os slots como ativos ou prontos para processamento.

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

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

Em seguida, classifique a lista que contém suas caixas delimitadoras em ordem decrescente com base na confiança.

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

Depois disso, crie uma lista para armazenar os resultados filtrados.

var results = new List<YoloBoundingBox>();

Comece a processar cada caixa delimitadora iterando sobre cada uma das caixas delimitadoras.

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

}

Dentro desse loop for, verifique se a caixa delimitadora atual pode ser processada.

if (isActiveBoxes[i])
{

}

Em caso afirmativo, adicione a caixa delimitadora à lista de resultados. Se os resultados excederem o limite especificado de caixas a serem extraídas, quebre o loop. Adicione o seguinte código dentro da instrução if.

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

if (results.Count >= limit)
    break;

Caso contrário, observe as caixas delimitadoras adjacentes. Adicione o seguinte código abaixo da verificação de limite de caixa.

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

}

Como a primeira caixa, se a caixa adjacente estiver ativa ou pronta para ser processada, use o método para verificar se a primeira caixa e a segunda caixa excedem o IntersectionOverUnion limite especificado. Adicione o seguinte código ao seu loop for-loop mais interno.

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

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

        if (activeCount <= 0)
            break;
    }
}

Fora do loop for mais interno que verifica as caixas delimitadoras adjacentes, veja se há caixas delimitadoras restantes a serem processadas. Se não, saia do for-loop externo.

if (activeCount <= 0)
    break;

Finalmente, fora do for-loop inicial do FilterBoundingBoxes método, retorne os resultados:

return results;

Excelente! Agora é hora de usar esse código junto com o modelo para pontuação.

Use o modelo para pontuação

Assim como no pós-processamento, existem algumas etapas nas etapas de pontuação. Para ajudar com isso, adicione uma classe que conterá a lógica de pontuação ao seu projeto.

  1. No Gerenciador de Soluções, clique com o botão direito do mouse no projeto e selecione Adicionar>Novo Item.

  2. Na caixa de diálogo Adicionar Novo Item, selecione Classe e altere o campo Nome para OnnxModelScorer.cs. Em seguida, selecione o botão Adicionar .

    O arquivo OnnxModelScorer.cs é aberto no editor de códigos. Adicione as seguintes using instruções à parte superior de OnnxModelScorer.cs:

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

    Dentro da definição de OnnxModelScorer classe, adicione as seguintes variáveis.

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

    Diretamente abaixo disso, crie um construtor para a OnnxModelScorer classe que inicializará as variáveis definidas anteriormente.

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

    Depois de criar o construtor, defina algumas estruturas que contenham variáveis relacionadas às configurações de imagem e modelo. Crie uma struct chamada ImageNetSettings para conter a altura e a largura esperadas como entrada para o modelo.

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

    Depois disso, crie outro struct chamado TinyYoloModelSettings que contenha os nomes das camadas de entrada e saída do modelo. Para visualizar o nome das camadas de entrada e saída do modelo, você pode usar uma ferramenta como 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";
    }
    

    Em seguida, crie o primeiro conjunto de métodos usados para pontuação. Crie o LoadModel método dentro da sua OnnxModelScorer classe.

    private ITransformer LoadModel(string modelLocation)
    {
    
    }
    

    Dentro do LoadModel método, adicione o seguinte código para registro.

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

    ML.NET pipelines precisam conhecer o esquema de dados para operar quando o Fit método é chamado. Neste caso, será utilizado um processo semelhante ao treinamento. No entanto, como nenhum treinamento real está acontecendo, é aceitável usar um vazio IDataView. Crie um novo IDataView para o pipeline a partir de uma lista vazia.

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

    Abaixo disso, defina o pipeline. O gasoduto será composto por quatro transformações.

    • LoadImages carrega a imagem como um bitmap.
    • ResizeImages Redimensiona a imagem para o tamanho especificado (neste caso, 416 x 416).
    • ExtractPixels altera a representação de pixel da imagem de um bitmap para um vetor numérico.
    • ApplyOnnxModel carrega o modelo ONNX e usa-o para pontuar os dados fornecidos.

    Defina seu pipeline no método abaixo da LoadModeldata variável.

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

    Agora é hora de instanciar o modelo de pontuação. Chame o método no pipeline e devolva-o Fit para processamento posterior.

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

Uma vez que o modelo é carregado, ele pode ser usado para fazer previsões. Para facilitar esse processo, crie um método chamado PredictDataUsingModel abaixo do LoadModel método.

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

}

Dentro do PredictDataUsingModel, adicione o seguinte código para registro.

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

Em seguida, use o Transform método para pontuar os dados.

IDataView scoredData = model.Transform(testData);

Extraia as probabilidades previstas e devolva-as para processamento adicional.

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

return probabilities;

Agora que ambas as etapas estão configuradas, combine-as em um único método. Abaixo do PredictDataUsingModel método, adicione um novo método chamado Score.

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

    return PredictDataUsingModel(data, model);
}

Quase lá! Agora é hora de colocar tudo em uso.

Detetar objetos

Agora que toda a configuração está concluída, é hora de detetar alguns objetos.

Pontuar e analisar as saídas do modelo

Abaixo da criação da mlContext variável, adicione uma instrução try-catch.

try
{

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

Dentro do bloco , comece a try implementar a lógica de deteção de objetos. Primeiro, carregue os dados em um IDataViewarquivo .

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

Em seguida, crie uma instância e OnnxModelScorer use-a para pontuar os dados carregados.

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

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

Agora é hora da etapa de pós-processamento. Crie uma instância e YoloOutputParser use-a para processar a saída do modelo.

YoloOutputParser parser = new YoloOutputParser();

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

Uma vez que a saída do modelo tenha sido processada, é hora de desenhar as caixas delimitadoras nas imagens.

Visualizar previsões

Depois que o modelo tiver pontuado as imagens e as saídas tiverem sido processadas, as caixas delimitadoras devem ser desenhadas na imagem. Para fazer isso, adicione um método chamado DrawBoundingBox abaixo o GetAbsolutePath método dentro de Program.cs.

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

}

Primeiro, carregue a imagem e obtenha as dimensões de altura e largura no DrawBoundingBox método.

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

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

Em seguida, crie um loop para cada um para iterar em cada uma das caixas delimitadoras detetadas pelo modelo.

foreach (var box in filteredBoundingBoxes)
{

}

Dentro do loop para cada um, obtenha as dimensões da caixa delimitadora.

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

Como as dimensões da caixa delimitadora correspondem à entrada do modelo do , dimensione as dimensões da caixa delimitadora para corresponder ao tamanho real da 416 x 416imagem.

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;

Em seguida, defina um modelo para o texto que aparecerá acima de cada caixa delimitadora. O texto conterá a classe do objeto dentro da respetiva caixa delimitadora, bem como a confiança.

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

Para desenhar na imagem, converta-a em um Graphics objeto.

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

}

Dentro do bloco de código, ajuste as configurações do using objeto gráfico Graphics .

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

Abaixo disso, defina as opções de fonte e cor para o texto e a caixa delimitadora.

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

Crie e preencha um retângulo acima da caixa delimitadora para conter o texto usando o FillRectangle método. Isso ajudará a contrastar o texto e melhorar a legibilidade.

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

Em seguida, desenhe o texto e a caixa delimitadora na imagem usando os DrawString métodos e DrawRectangle .

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

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

Fora do loop para cada um, adicione código para salvar as imagens no outputFolder.

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

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

Para obter comentários adicionais de que o aplicativo está fazendo previsões conforme esperado em tempo de execução, adicione um método chamado LogDetectedObjects abaixo do DrawBoundingBox método no arquivo Program.cs para enviar os objetos detetados para o console.

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

Agora que você tem métodos auxiliares para criar feedback visual a partir das previsões, adicione um for-loop para iterar sobre cada uma das imagens pontuadas.

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

}

Dentro do for-loop, obtenha o nome do arquivo de imagem e as caixas delimitadoras associadas a ele.

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

Abaixo disso, use o DrawBoundingBox método para desenhar as caixas delimitadoras na imagem.

DrawBoundingBox(imagesFolder, outputFolder, imageFileName, detectedObjects);

Por fim, use o método para exportar previsões para o LogDetectedObjects console.

LogDetectedObjects(imageFileName, detectedObjects);

Após a instrução try-catch, adicione lógica adicional para indicar que o processo foi concluído em execução.

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

Está feito!

Resultados

Depois de seguir as etapas anteriores, execute o aplicativo de console (Ctrl + F5). Seus resultados devem ser semelhantes aos resultados a seguir. Você pode ver avisos ou mensagens de processamento, mas essas mensagens foram removidas dos seguintes resultados para maior clareza.

=====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 ========

Para ver as imagens com caixas delimitadoras, navegue até o assets/images/output/ diretório. Abaixo está um exemplo de uma das imagens processadas.

Sample processed image of a dining room

Parabéns! Agora você criou com sucesso um modelo de aprendizado de máquina para deteção de objetos reutilizando um modelo pré-treinado ONNX no ML.NET.

Você pode encontrar o código-fonte deste tutorial no repositório dotnet/machinelearning-samples .

Neste tutorial, ficou a saber como:

  • Entenda o problema
  • Saiba o que é ONNX e como funciona com ML.NET
  • Entenda o modelo
  • Reutilizar o modelo pré-treinado
  • Detetar objetos com um modelo carregado

Confira os exemplos de aprendizado de máquina repositório GitHub para explorar um exemplo de deteção de objeto expandido.