Partilhar via


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 encurtar o processo de treino.

Neste tutorial, você 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 .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.

Capturas de tela mostrando Classificação de Imagem versus Classificação de Objeto.

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

  • Self-Driving Carros
  • Robótica
  • Deteção de rosto
  • Segurança no Trabalho
  • Contagem de objetos
  • Reconhecimento de Atividades

Selecione um modelo de aprendizagem profunda

A aprendizagem profunda é um subconjunto da aprendizagem automática. 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.

Camada de entrada sendo dividida em camadas ocultas e, em seguida, camada de saída

O modelo YOLO leva 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 grade de 13 x 13, 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.

Diagrama de formatos suportados por ONNX sendo usados.

O modelo Tiny YOLOv2 pré-treinado é armazenado no formato ONNX, uma representação serializada das camadas e padrões aprendidos dessas camadas. No ML.NET, a interoperabilidade com o ONNX é alcançada 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 treinamento. O pacote OnnxTransformer aproveita o ONNX Runtime para carregar um modelo ONNX e usá-lo para fazer previsões com base na entrada fornecida.

Fluxo de dados do ficheiro ONNX no 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 um aplicativo de console

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

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

  3. Instale o Microsoft.ML pacote NuGet:

    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 em seu projeto e selecione Gerenciar pacotes NuGet.
    • Escolha "nuget.org" como a origem do pacote, selecione a guia Procurar, procure Microsoft.ML.
    • Selecione o botão Instalar.
    • Selecione o botão OK na caixa de diálogo Pré-visualizar Alterações e, em seguida, selecione o botão Concordo na caixa de diálogo Aceitação de Licença se concordar com os termos de licença para os pacotes mencionados.
    • Repita estas etapas para Microsoft.Windows.Compatibility, Microsoft.ML.ImageAnalytics, Microsoft.ML.OnnxTransformer e Microsoft.ML.OnnxRuntime.

Prepare os seus dados e o modelo pré-treinado

  1. Faça o download O arquivo zip do diretório de ativos do projeto e descompacte.

  2. Copie o diretório assets para o diretório ObjectDetection do projeto. 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 projeto ObjectDetection e renomeie-o para . 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 diretivas using adicionais à 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 de 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 diretivas using, 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 AdicionarNovo Item.

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

    O arquivo ImageNetData.cs é aberto no editor de códigos. Adicione a seguinte diretiva using ao topo da 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 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 campos String:

    • 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 caminho de 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 selecione AdicionarNovo Item.

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

    O arquivo ImageNetPrediction.cs é aberto no editor de códigos. Adicione a seguinte diretiva using ao topo da ImageNetPrediction.cs:

    using Microsoft.ML.Data;
    

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

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

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

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

Inicializar variáveis

A classe MLContext é um ponto de partida para todas as operações ML.NET, e inicializar mlContext cria um novo ambiente de ML.NET que pode ser partilhado entre os objetos de fluxo de trabalho de criação de modelo. É semelhante, conceitualmente, ao 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 pós-processo

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

Exemplo de grade à esquerda e de caixa delimitadora à direita

  • 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 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 objetividade.
  • Probabilidades de cada classe p1-p20 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 "YoloParser".

Desenhar caixas delimitadoras e dimensões

Os dados gerados pelo modelo contêm coordenadas e dimensões das caixas de delimitação 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 AdicionarNovo Item.

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

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

    Adicione o seguinte código para a 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 seguintes propriedades float:

    • 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 as caixas delimitadoras.

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

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

    O arquivo YoloBoundingBox.cs é aberto no editor de códigos. Adicionar a seguinte diretiva using ao topo 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 DimensionsBase para conter as dimensões da respetiva caixa delimitadora.

    public class BoundingBoxDimensions : DimensionsBase { }
    

    Remova a definição de classe YoloBoundingBox existente e adicione o seguinte código para a classe YoloBoundingBox 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 as dimensões da caixa delimitadora.
    • Label contém a classe de objeto detetada na caixa delimitadora.
    • Confidence contém a confiança da classe.
    • Rect contém a representação retangular das dimensões da caixa de contenção.
    • 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 AdicionarNovo Item.

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

    O arquivo YoloOutputParser.cs é aberto no editor de códigos. Adicione as seguintes diretivas using ao topo da 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 contenha as dimensões de cada uma das células na imagem. Adicione o seguinte código para a classe CellDimensions que herda da classe DimensionsBase na parte superior da definição de classe YoloOutputParser.

    class CellDimensions : DimensionsBase { }
    
  3. Dentro da definição de classe YoloOutputParser, 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 grade em que a imagem está 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 dentro de 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 da imagem.
    • CELL_HEIGHT é a altura de uma célula na grade da 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 imagem de entrada 416px x 416px 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 proporções predefinidas 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 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 de que o modelo detetou um objeto e usa a função Sigmoid para transformá-lo em uma porcentagem.
  • MapBoundingBoxToCell usa as dimensões da caixa delimitadora e as atribui à 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 GetOffset e as transforma numa distribuição de probabilidades usando o método Softmax.
  • GetTopResult seleciona a classe da lista de classes previstas com a maior probabilidade.
  • IntersectionOverUnion filtra caixas delimitadoras sobrepostas com probabilidades mais baixas.

Coloque 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 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 as suas caixas delimitadoras e defina variáveis dentro do método ParseOutputs.

var boxes = new List<YoloBoundingBox>();

Cada imagem é dividida em uma grelha de 13 x 13 células. Cada célula contém cinco caixas delimitadoras. Abaixo da variável boxes, 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 o posicionamento inicial da caixa atual dentro da saída do modelo unidimensional.

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

Diretamente abaixo disto, 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, utilize o método MapBoundingBoxToCell para mapear a caixa delimitadora atual para a célula que está a ser 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 a processar 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 calcular sua pontuação.

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

Utilize o topScore para novamente 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 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 tiverem sido processadas, retorne a lista de boxes. Adicione a seguinte instrução return abaixo do for-loop mais externo no método ParseOutputs.

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 método ParseOutputs:

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

}

Dentro do método FilterBoundingBoxes, 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, interrompa o ciclo. 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 a seguinte linha de código abaixo da verificação do limite da 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 IntersectionOverUnion para verificar se a primeira caixa e a segunda caixa excedem o 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, verifique 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 método FilterBoundingBoxes, retorne os resultados:

return results;

Ótimo! 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 na 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 Adicionar.

    O arquivo OnnxModelScorer.cs é aberto no editor de códigos. Adicione as seguintes diretivas using ao topo da 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 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 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 estruturas que contenham variáveis relacionadas às configurações de imagem e modelo. Crie uma estrutura 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 método LoadModel dentro da sua classe OnnxModelScorer.

    private ITransformer LoadModel(string modelLocation)
    {
    
    }
    

    Dentro do método LoadModel, 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 método Fit é chamado. Neste caso, será utilizado um processo semelhante ao treinamento. No entanto, como nenhum treinamento real está acontecendo, é aceitável usar um IDataViewvazio. 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 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 de pontuação. Chame o método Fit no fluxo de trabalho e retorne-o 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 método LoadModel.

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 método Transform 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 método PredictDataUsingModel, adicione um novo método chamado Score.

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

    return PredictDataUsingModel(data, model);
}

Está quase! Agora é hora de colocar tudo em uso.

Detetar objetos

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

Avaliar e analisar os resultados 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 deteçã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));

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

Visualizar previsões

Após o modelo ter classificado as imagens e os resultados terem sido processados, as caixas delimitadoras devem 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 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 416 x 416, ajuste essas dimensões 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 respetiva caixa delimitadora, bem como o nível de 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 do 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 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 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 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 método DrawBoundingBox no arquivo de Program.cs para exportar 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 avaliadas.

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 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 para o console.

LogDetectedObjects(imageFileName, detectedObjects);

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

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

É isso!

Resultados obtidos

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 diretório assets/images/output/. Abaixo está um exemplo de uma das imagens processadas.

Amostra de imagem processada de uma sala de jantar

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

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

Neste tutorial, você aprendeu 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 o repositório GitHub dos exemplos de aprendizagem automática para explorar um exemplo expandido de deteção de objetos.