Tutorial: Detectar objetos usando ONNX em ML.NET

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

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

Neste tutorial, você aprenderá a:

  • Compreender o problema
  • Saiba o que é o ONNX e como ele funciona com o ML.NET
  • Entender o modelo
  • Reutilizar o modelo pré-treinado
  • Detectar objetos com um modelo carregado

Pré-requisitos

Visão geral do exemplo de detecção de objetos ONNX

Este exemplo cria um aplicativo de console .NET Core que detecta objetos em 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 é a detecção de objetos?

A detecção de objetos é um problema da pesquisa visual computacional. Embora esteja bem relacionada à classificação de imagem, a detecção de objetos executa a classificação de imagem em uma escala mais granular. A detecção de objetos localiza e categoriza entidades dentro de imagens. Os modelos de detecção de objetos são normalmente treinados usando redes neurais e de aprendizado profundo. Para obter mais informações, consulte Aprendizado profundo versus aprendizado de máquina.

Use a detecçã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 detecção de objetos incluem:

  • Carros autônomos
  • Robótica
  • Detecção Facial
  • Segurança do local de trabalho
  • Contagem de objetos
  • Reconhecimento de atividades

Selecionar um modelo de aprendizado profundo

O aprendizado profundo é um subconjunto de aprendizado de máquina. Para treinar modelos de aprendizado profundo, grandes quantidades de dados são necessárias. 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 é conhecida como redes neurais artificiais. Quanto mais camadas em uma rede, "mais profunda" ela será, tornando-a uma rede neural profunda.

Há diferentes tipos de redes neurais, as mais comuns são a MLP (Perceptron Multicamadas), a CNN (Rede Neural de Convolução) e a RNN (Rede Neural Recorrente). A mais básica é a MLP, que mapeia um conjunto de entradas para um conjunto de saídas. Essa rede neural é boa quando os dados não têm um componente espacial ou de tempo. A CNN usa camadas de convolução para processar informações espaciais contidas nos dados. Um bom caso de uso para as CNNs é o processamento de imagens para detectar a presença de um recurso em uma região de uma imagem (por exemplo, há um nariz no centro de uma imagem?). Por fim, as RNNs permitem que a persistência do estado ou da memória seja usada como entrada. As RNNs são usadas para análise de série temporal, em que a ordenação sequencial e o contexto dos eventos são importantes.

Entender o modelo

A detecção de objetos é uma tarefa de processamento de imagem. Portanto, os modelos de aprendizado profundo mais treinados para resolver esse problema são as 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 do 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, em que cada camada conteria o nome da camada junto com as dimensões da respectiva entrada/saída. As estruturas de dados usadas para descrever as entradas e as saídas do modelo são conhecidas como tensores. Os tensores podem ser entendidos como contêineres que armazenam dados em N dimensões. No caso do Tiny YOLOv2, o nome da camada de entrada é image e ele 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 usa uma imagem 3(RGB) x 416px x 416px. O modelo usa essa entrada e a passa pelas diferentes camadas para produzir uma saída. A saída divide a imagem de entrada em uma grade 13 x 13, com cada célula na grade consistindo em 125 valores.

O que é um modelo ONNX?

O ONNX (Open Neural Network Exchange) é um formato de software livre para modelos de IA. O ONNX é compatível com a interoperabilidade entre estruturas. Isso significa que você pode treinar um modelo em uma das muitas estruturas de aprendizado de máquina populares, como PyTorch, convertê-la em formato ONNX e consumir o modelo ONNX em uma estrutura diferente, como ML.NET. Para saber mais, visite o site do 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 dos padrões aprendidos dessas camadas. No ML.NET, a interoperabilidade com o ONNX é obtida com os pacotes NuGet ImageAnalytics e OnnxTransformer. O pacote ImageAnalytics 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 de treinamento. O pacote OnnxTransformer aproveita o runtime do ONNX 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 do .NET Console

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

Criar um aplicativo de console

  1. Crie um Aplicativo de console em C# chamado "ObjectDetection". Clique no botão Avançar.

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

  3. Instale o pacote NuGet Microsoft.ML:

    Observação

    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 no seu projeto e selecione Gerenciar Pacotes NuGet.
    • Escolha "nuget.org" como a fonte do pacote, selecione a guia Browse, procure por Microsoft.ML.
    • Selecione o botão Instalar.
    • Selecione o botão OK na caixa de diálogo Visualizar Alterações e selecione o botão Aceito na caixa de diálogo Aceitação da Licença, se concordar com o termos de licença para os pacotes listados.
    • Repita essas etapas para Microsoft.Windows.Compatibility, Microsoft.ML.ImageAnalytics, Microsoft.ML.OnnxTransformer e Microsoft.ML.OnnxRuntime.

Prepare seus dados e um modelo pré-treinado

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

  2. Copie o diretório assets no diretório do 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 download do modelo Tiny YOLOv2 do ONNX Model Zoo.

  4. Copie o arquivo model.onnx no diretório ObjectDetection do projeto assets\Model e renomeie-o como 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 nos subdiretórios do ativo e selecionePropriedades. Em Avançado, altere o valor de Copiar para Diretório de Saída para Copiar se for mais novo.

Criar classes e definir demarcadores

Abra o arquivo Program.cs e adicione as seguintes instruções using complementares à 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 método GetAbsolutePath 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 em 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 os dados de entrada e as 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, em seguida, 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 instrução using à parte superior do 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 classe ImageNetData do 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 conjunto de dados de entrada e tem os seguintes campos String:

    • Um ImagePath que contém o caminho no qual a imagem é armazenada.
    • Um Label que 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 caminho imageFolder especificado e os retorna como uma coleção de objetos ImageNetData.

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, em seguida, 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 instrução using à parte superior do ImageNetPrediction.cs:

    using Microsoft.ML.Data;
    

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

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

    O ImageNetPrediction é a classe de dados de previsão e conta com os seguintes float[] campos:

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

Inicializar variáveis

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

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

MLContext mlContext = new MLContext();

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

O modelo segmenta uma imagem em uma grade 13 x 13, em que cada célula de grade é 32px x 32px. Cada célula de grade contém cinco 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 relativa à célula da grade à qual ela está associada.
  • y: a posição y do centro da caixa delimitadora relativa à célula da grade à qual ela 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 objeções.
  • p1-p20: probabilidades de classe para cada uma das 20 classes previstas pelo modelo.

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

A saída gerada pelo modelo ONNX pré-treinado é uma matriz float de cumprimento 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. Criar 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, em seguida, 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 instruções using e a definição de classe existente.

    Adicione o seguinte código à classe DimensionsBase 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 propriedades float a seguir:

    • 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, em seguida, 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 instrução using à parte superior do 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 DimensionsBase para conter as dimensões da respectiva caixa delimitadora.

    public class BoundingBoxDimensions : DimensionsBase { }
    

    Remova a definição de classe YoloBoundingBox existente e adicione o seguinte código para a classe YoloBoundingBox do 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 propriedades a seguir:

    • Dimensions contém as dimensões da caixa delimitadora.
    • Label contém a classe de objeto detectada na caixa delimitadora.
    • Confidence contém a confiança da classe.
    • Rect contém a representação de retângulo das dimensões da caixa delimitadora.
    • BoxColor contém a cor associada à respectiva classe usada para desenhar na imagem.

Criar o analisador

Agora que as classes para dimensões e caixas delimitadoras estão 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, em seguida, 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 instruções using à parte superior de YoloOutputParser.cs:

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

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

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

    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 grade em que a imagem é dividida.
    • COL_COUNT é o número de colunas na grade em que a imagem é dividida.
    • CHANNEL_COUNT é o número total de valores contidos em uma célula da grade.
    • BOXES_PER_CELL é o número de caixas delimitadoras em uma célula.
    • BOX_INFO_FEATURE_COUNT é o número de recursos contidos em uma 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 de imagens.
    • CELL_HEIGHT é a altura de uma célula na grade de imagens.
    • 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 imagem de entrada 416px x 416px em uma grade de células com o tamanho 13 x 13. O conteúdo de cada célula é 32px x 32px. Dentro de cada célula, há cinco caixas delimitadoras que contêm cinco recursos (x, y, largura, altura, confiança). Além disso, cada caixa delimitadora contém a probabilidade de cada uma das classes que, nesse caso, é 20. Portanto, cada célula contém 125 partes de informações (cinco recursos + 20 probabilidades de classe).

Crie uma lista de âncoras abaixo de channelStride para todas as cinco 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 taxas de altura e largura pré-definidas das caixas delimitadoras. A maioria dos objetos ou classes detectadas por um modelo tem taxas semelhantes. Isso é importante quando se trata de criar caixas delimitadoras. Em vez de prever as caixas delimitadoras, o deslocamento das dimensões pré-definidas é calculado, reduzindo a computação necessária para prever a caixa delimitadora. Normalmente, essas taxas de âncora são calculadas com base no conjunto de dados usado. Nesse caso, como o conjunto de dados é conhecido e os valores foram previamente computados, as âncoras podem ser embutidas em código.

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

Adicione sua lista de rótulos abaixo de 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"
};

Há cores associadas a cada uma das classes. Atribua suas cores de classe abaixo de 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 gera 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 de um modelo unidimensional para a posição correspondente em um tensor 125 x 13 x 13.
  • ExtractBoundingBoxes: extrai as dimensões da caixa delimitadora usando o método GetOffset da saída do modelo.
  • GetConfidence extrai o valor de confiança que indica a certeza do modelo de que detectou um objeto e usa a função Sigmoid para transformá-lo em uma porcentagem.
  • MapBoundingBoxToCell: usa as dimensões da caixa delimitadora e as mapeia para sua respectiva célula na imagem.
  • ExtractClasses: extrai as previsões de classe para a caixa delimitadora da saída do modelo usando o método GetOffset e as transforma em uma distribuição de probabilidade usando o método Softmax.
  • GetTopResult: seleciona a classe na lista de classes previstas com a maior probabilidade.
  • IntersectionOverUnion: filtra as caixas delimitadoras sobrepostas com probabilidades inferiores.

Adicione o código para todos os métodos auxiliares abaixo de 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 método IntersectionOverUnion, crie o método ParseOutputs 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 defina variáveis dentro do método ParseOutputs.

var boxes = new List<YoloBoundingBox>();

Cada imagem é dividida em uma grade de células 13 x 13. Cada célula contém cinco caixas delimitadoras. Abaixo da variável boxes, adicione o 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 na saída de um modelo unidimensional.

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

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

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

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

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

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

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

Antes de efetuar qualquer processamento adicional, verifique se seu valor de confiança é maior que o limite fornecido. Se não for, 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 método ExtractClasses.

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

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

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

Use o topScore para novamente manter somente as caixas delimitadoras que estão acima do limite especificado.

if (topScore < threshold)
    continue;

Por fim, se a caixa delimitadora atual exceder o limite, crie um objeto BoundingBox e adicione-o à lista 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]
});

Depois que todas as células da imagem forem processadas, retorne à lista boxes. Adicione a seguinte instrução de retorno abaixo do loop for mais externo no método ParseOutputs.

return boxes;

Filtrar as caixas sobrepostas

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

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

}

No método FilterBoundingBoxes, comece criando uma matriz igual ao tamanho das caixas detectadas 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 as caixas delimitadoras em ordem decrescente com base em 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 manter os resultados filtrados.

var results = new List<YoloBoundingBox>();

Comece a processar cada caixa delimitadora fazendo a iteração em 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])
{

}

Se sim, adicione a caixa delimitadora à lista de resultados. Se os resultados ultrapassarem o limite especificado de caixas a serem extraídas, interrompa 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, examine as caixas delimitadoras adjacentes. Adicione o código a seguir abaixo da verificação de limite de caixa.

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

}

Como na primeira caixa, se a caixa adjacente estiver ativa ou pronta para ser processada, use o método IntersectionOverUnion para verificar se a primeira caixa e a segunda caixa excedem o limite especificado. Adicione o seguinte código ao seu loop for 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 caixas delimitadoras adjacentes, veja se há alguma caixa delimitadora restante a ser processada. Se não houver, interrompa o loop for externo.

if (activeCount <= 0)
    break;

Por fim, fora do loop for inicial do método FilterBoundingBoxes, retorne os resultados:

return results;

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

Usar o modelo para pontuação

Assim como ocorre com o pós-processamento, há 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 instruções using à 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 classe OnnxModelScorer, adicione as variáveis a seguir.

    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 classe OnnxModelScorer 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 structs que contêm variáveis relacionadas às configurações de imagem e modelo. Crie um struct chamado 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 contém 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 o 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 usado para pontuação. Crie o método LoadModel dentro de sua classe OnnxModelScorer.

    private ITransformer LoadModel(string modelLocation)
    {
    
    }
    

    No método LoadModel, adicione o código a seguir para registrar em log.

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

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

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

    Abaixo disso, defina o pipeline. O pipeline consistirá em quatro transformações.

    • LoadImages carrega a imagem como um bitmap.
    • ResizeImages redimensiona a imagem para o tamanho especificado (nesse 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 o usa para pontuar os dados fornecidos.

    Defina o pipeline no método LoadModel abaixo da variável 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 }));
    

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

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

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

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

}

No PredictDataUsingModel, adicione o código a seguir para registrar em log.

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

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

IDataView scoredData = model.Transform(testData);

Extraia as probabilidades previstas e retorne-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 método PredictDataUsingModel, 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.

Detectar objetos

Agora que toda a configuração foi concluída, é hora de detectar alguns objetos.

Pontuar e analisar saídas do modelo

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

try
{

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

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

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

Em seguida, crie uma instância de OnnxModelScorer e 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 de YoloOutputParser e 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));

Após o processamento da saída do modelo, é hora de desenhar as caixas delimitadoras nas imagens.

Visualizar previsões

Depois que o modelo pontua as imagens e as saídas são processadas, as caixas delimitadoras precisam ser desenhadas na imagem. Para fazer isso, adicione um método chamado DrawBoundingBox abaixo do método GetAbsolutePath 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 método DrawBoundingBox.

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

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

Em seguida, crie um loop for-each para iterar sobre cada uma das caixas delimitadoras detectadas pelo modelo.

foreach (var box in filteredBoundingBoxes)
{

}

Dentro do loop for-each, 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 de 416 x 416, dimensione as dimensões da caixa delimitadora para corresponder ao tamanho real da imagem.

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 respectiva caixa delimitadora, bem como a confiança.

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

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

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

}

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

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 método FillRectangle. Isso ajudará a comparar o texto e a 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 métodos DrawString e DrawRectangle.

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

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

Fora do loop for-each, adicione o 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 no tempo de execução, adicione um método chamado LogDetectedObjects abaixo do método DrawBoundingBox no arquivo Program.cs para gerar os objetos detectados no 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 comentários visuais com base nas previsões, adicione um loop for para iterar em cada uma das imagens pontuadas.

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

}

Dentro do loop for, 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 método DrawBoundingBox para desenhar as caixas delimitadoras na imagem.

DrawBoundingBox(imagesFolder, outputFolder, imageFileName, detectedObjects);

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

LogDetectedObjects(imageFileName, detectedObjects);

Após a instrução try-catch, adicione a lógica complementar para indicar que o processo terminou a execução.

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

É isso!

Resultados

Depois de seguir as etapas anteriores, execute o aplicativo de console (Ctrl+F5). O resultado deverá ser semelhante à seguinte saída. Você poderá ver avisos ou mensagens de processamento, mas essas mensagens foram removidas dos resultados a seguir 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 as caixas delimitadoras, navegue até o diretório assets/images/output/. Abaixo está um exemplo de uma das imagens processadas.

Sample processed image of a dining room

Parabéns! Você criou com êxito um modelo de machine learning para detecção de objetos reutilizando um modelo ONNX pré-treinado no ML.NET.

É possível encontrar o código-fonte deste tutorial no repositório dotnet/machinelearning-samples.

Neste tutorial, você aprendeu a:

  • Compreender o problema
  • Saiba o que é o ONNX e como ele funciona com o ML.NET
  • Entender o modelo
  • Reutilizar o modelo pré-treinado
  • Detectar objetos com um modelo carregado

Confira o repositório GitHub de exemplos de Machine Learning para explorar um exemplo de detecção de objetos expandido.