Tutorial: Detección de objetos con ONNX en ML.NET

Aprenda a usar en ML.NET un modelo ONNX previamente entrenado para detectar objetos en imágenes.

Entrenar un modelo de detección de objetos desde cero requiere establecer millones de parámetros, una enorme cantidad de datos de aprendizaje etiquetados y una gran cantidad de recursos informáticos (cientos de horas de GPU). El uso de un modelo entrenado previamente le permite abreviar el proceso de entrenamiento.

En este tutorial aprenderá a:

  • Entender el problema
  • Conocer qué es ONNX y cómo funciona con ML.NET
  • Entender el modelo
  • Volver a usar el modelo entrenado previamente
  • Detectar objetos con un modelo cargado

Requisitos previos

Información general del ejemplo de detección de objetos de ONNX

En este ejemplo se crea una aplicación de consola de .NET Core que detecta objetos dentro de una imagen mediante un modelo de aprendizaje profundo de ONNX previamente entrenado. El código de este ejemplo se puede encontrar en el repositorio dotnet/machinelearning-samples en GitHub.

¿Qué es la detección de objetos?

La detección de objetos es un problema de visión informática. Si bien está estrechamente relacionada con la clasificación de imágenes, la detección de objetos realiza la clasificación de imágenes a una escala más granular. La detección de objetos ubica y categoriza entidades dentro de las imágenes. Los modelos de detección de objetos se entrenan normalmente mediante el aprendizaje profundo y las redes neuronales. Consulte Aprendizaje profundo frente a aprendizaje automático para obtener más información.

Use la detección de objetos cuando las imágenes contengan varios objetos de tipos diferentes.

Screenshots showing Image Classification versus Object Classification.

Entre algunos casos de uso para la detección de objetos se incluyen:

  • Automóviles sin conductor
  • Robótica
  • Detección facial
  • Seguridad en el lugar de trabajo
  • Recuento de objetos
  • Reconocimiento de actividades

Selección de un modelo de aprendizaje profundo

El aprendizaje profundo es un subconjunto del aprendizaje automático. Para entrenar modelos de aprendizaje profundo, se necesitan grandes cantidades de datos. Los patrones de los datos se representan mediante una serie de capas. Las relaciones de los datos se codifican como conexiones entre las capas, que contienen ponderaciones. Cuanto mayor sea la ponderación, más fuerte será la relación. En conjunto, esta serie de capas y conexiones se conocen como redes neuronales artificiales. Cuantas más capas haya en una red, "más profunda" será, lo que la convierte en una red neuronal profunda.

Hay diferentes tipos de redes neuronales, las más comunes son perceptrón multicapa (MLP), red neuronal circunvolucional (CNN) y red neuronal recurrente (RNN). La más básica es MLP, que asigna un conjunto de entradas a un conjunto de salidas. Esta red neuronal es la adecuada cuando los datos no tienen un componente espacial ni temporal. CNN aprovecha las capas circunvolucionales para procesar la información espacial contenida en los datos. Un buen caso de uso de las CNN es el procesamiento de imágenes para detectar la presencia de una característica en una región de una imagen (por ejemplo, ¿hay una nariz en el centro de una imagen?). Por último, las RNN permiten la persistencia del estado o la memoria que se va a usar como entrada. Las RNN se usan para el análisis de series temporales, donde la ordenación secuencial y el contexto de eventos son importantes.

Entender el modelo

La detección de objetos es una tarea de procesamiento de imágenes. Por lo tanto, la mayoría de los modelos de aprendizaje profundo entrenados para solucionar este problema son CNN. El modelo que se usa en este tutorial es el Tiny YOLOv2, una versión más compacta del modelo YOLOv2 que se describe en el documento: "YOLO9000: Better, Faster, Stronger" de Redmon y Farhadi. Tiny YOLOv2 se entrena en el conjunto de datos Pascal VOC y se compone de 15 capas que pueden predecir 20 clases diferentes de objetos. Dado que Tiny YOLOv2 es una versión comprimida del modelo YOLOv2 original, se logra velocidad a cambio de precisión. Los diferentes niveles que componen el modelo se pueden visualizar mediante herramientas como Netron. Al inspeccionar el modelo, se produciría una asignación de las conexiones entre todas las capas que componen la red neuronal, donde cada capa contendría el nombre de la capa, junto con las dimensiones de la entrada y la salida correspondientes. Las estructuras de datos que se usan para describir las entradas y salidas del modelo se conocen como tensores. Los tensores se pueden considerar como contenedores que almacenan datos en N dimensiones. En el caso de Tiny YOLOv2, el nombre de la capa de entrada es image y espera un tensor de dimensiones 3 x 416 x 416. El nombre de la capa de salida es grid y genera un tensor de salida de dimensiones 125 x 13 x 13.

Input layer being split into hidden layers, then output layer

El modelo YOLO toma una imagen 3(RGB) x 416px x 416px. El modelo toma esta entrada y la pasa a través de las diferentes capas para generar una salida. El resultado divide la imagen de entrada en una cuadrícula de 13 x 13, y cada celda de la cuadrícula consta de 125 valores.

¿Qué es un modelo de ONNX?

Open Neural Network Exchange (ONNX) es un formato de código abierto para los modelos de IA. ONNX admite la interoperabilidad entre marcos. Esto significa que puede entrenar un modelo en uno de los muchos marcos de aprendizaje automático populares, como PyTorch, convertirlo en formato ONNX y consumir el modelo ONNX en otro marco, como ML.NET. Para más información, visite el sitio web de ONNX.

Diagram of ONNX supported formats being used.

El modelo Tiny YOLOv2 entrenado previamente se almacena en formato ONNX, una representación serializada de las capas y los patrones aprendidos de esas capas. En ML.NET, la interoperabilidad con ONNX se logra con los paquetes NuGet ImageAnalytics y OnnxTransformer. El paquete ImageAnalytics contiene una serie de transformaciones que toman una imagen y la codifican en valores numéricos que se pueden usar como entrada en una canalización de entrenamiento o de predicción. El paquete OnnxTransformer aprovecha el tiempo de ejecución de ONNX para cargar un modelo de ONNX y usarlo para hacer predicciones basadas en la entrada proporcionada.

Data flow of ONNX file into the ONNX Runtime.

Configuración del proyecto de consola de .NET

Ahora que tiene un conocimiento general de lo que es ONNX y de cómo funciona Tiny YOLOv2, es el momento de compilar la aplicación.

Creación de una aplicación de consola

  1. Cree una aplicación de consola en C# llamada "ObjectDetection". Haga clic en el botón Next (Siguiente).

  2. Seleccione .NET 6 como marco de trabajo que va a usarse. Haga clic en el botón Crear.

  3. Instale el paquete NuGet Microsoft.ML:

    Nota

    En este ejemplo se usa la versión estable más reciente de los paquetes NuGet mencionados, a menos que se indique lo contrario.

    • En el Explorador de soluciones, haga clic con el botón derecho en Administrar paquetes NuGet.
    • Elija "nuget.org" como origen del paquete, seleccione la pestaña Examinar y busque Microsoft.ML.
    • Seleccione el botón Instalar.
    • Seleccione el botón Aceptar en el cuadro de diálogo Vista previa de cambios y, a continuación, seleccione el botón Acepto del cuadro de diálogo Aceptación de la licencia en caso de que esté de acuerdo con los términos de licencia de los paquetes mostrados.
    • Repita estos pasos para Microsoft.Windows.Compatibility, Microsoft.ML.ImageAnalytics, Microsoft.ML.OnnxTransformer y Microsoft.ML.OnnxRuntime.

Preparar los datos y el modelo entrenado previamente

  1. Descargue el archivo ZIP del directorio de recursos del proyecto y descomprímalo.

  2. Copie el directorio assets en el directorio de proyecto ObjectDetection. Este directorio y sus subdirectorios contienen los archivos de imagen (excepto los del modelo de Tiny YOLOv2, que descargará y agregará en el paso siguiente) que se necesitan en este tutorial.

  3. Descargue el modelo de Tiny YOLOv2 de ONNX Model Zoo.

  4. Copie el archivo model.onnx en el directorio assets\Model del proyecto ObjectDetection y cámbiele el nombre a TinyYolo2_model.onnx. Este directorio contiene el modelo necesario para este tutorial.

  5. En el Explorador de soluciones, haga clic con el botón derecho en cada uno de los archivos del directorio de recursos y los subdirectorios y seleccione Propiedades. En Avanzadas, cambie el valor de Copiar en el directorio de salida por Copiar si es posterior.

Crear clases y definir rutas de acceso

Abra el archivoProgram.cs y agregue las instrucciones using adicionales siguientes a la parte superior del archivo:

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

A continuación, defina las rutas de acceso de los distintos recursos.

  1. Primero, cree el método GetAbsolutePath al final del archivo 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. A continuación, debajo de las instrucciones using, cree campos para almacenar la ubicación de los recursos.

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

Agregue un nuevo directorio al proyecto para almacenar los datos de entrada y las clases de predicción.

En el Explorador de soluciones, haga clic con el botón derecho en el proyecto y, luego, seleccione Agregar>Nueva carpeta. Cuando la nueva carpeta aparezca en el Explorador de soluciones, asígnele el nombre "DataStructures".

Cree la clase de datos de entrada en el directorio DataStructures recién creado.

  1. En el Explorador de soluciones, haga clic con el botón derecho en el directorio DataStructures y luego seleccione Agregar>Nuevo elemento.

  2. En el cuadro de diálogo Agregar nuevo elemento, seleccione Clase y cambie el campo Nombre a ImageNetData.cs. A continuación, seleccione el botón Agregar.

    El archivo ImageNetData.cs se abre en el editor de código. Agregue la instrucción using siguiente al principio del archivo ImageNetData.cs:

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

    Quite la definición de clase existente y agregue el código siguiente para la clase ImageNetData al archivo 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 es la clase de datos de imagen de entrada y tiene los campos String siguientes:

    • ImagePath contiene la ruta de acceso donde se almacena la imagen.
    • Label contiene el nombre del archivo.

    Además, ImageNetData contiene un método ReadFromFile que carga varios archivos de imagen almacenados en la ruta imageFolder especificada y los devuelve como una colección de objetos ImageNetData.

Cree la clase de predicción en el directorio DataStructures.

  1. En el Explorador de soluciones, haga clic con el botón derecho en el directorio DataStructures y luego seleccione Agregar>Nuevo elemento.

  2. En el cuadro de diálogo Agregar nuevo elemento, seleccione Clase y cambie el campo Nombre a ImageNetPrediction.cs. A continuación, seleccione el botón Agregar.

    El archivo ImageNetPrediction.cs se abre en el editor de código. Agregue la instrucción using siguiente al principio del archivo ImageNetPrediction.cs:

    using Microsoft.ML.Data;
    

    Quite la definición de clase existente y agregue el código siguiente para la clase ImageNetPrediction al archivo ImageNetPrediction.cs:

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

    ImageNetPrediction es la clase de datos de predicción y tiene el campo float[] siguiente:

    • PredictedLabels contiene las dimensiones, la puntuación de calidad de objeto y las probabilidades de clase para cada uno de los cuadros de límite detectados en una imagen.

Inicialización de variables

La clase MLContext es un punto de partida para todas las operaciones de ML.NET. Al inicializar mlContext, se crea un entorno de ML.NET que se puede compartir entre los objetos del flujo de trabajo de creación de modelos. Como concepto, se parece a DBContext en Entity Framework.

Inicialice la variable mlContext con una instancia nueva de MLContext mediante la incorporación de la línea siguiente debajo del método outputFolder.

MLContext mlContext = new MLContext();

Crear un analizador para el procesamiento posterior de las salidas del modelo

El modelo segmenta una imagen en una cuadrícula 13 x 13, donde cada celda de la cuadrícula mide 32px x 32px. Cada celda de la cuadrícula contiene 5 rectángulos delimitadores de objeto posibles. Un rectángulo delimitador tiene 25 elementos:

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

  • x, posición x del centro del rectángulo delimitador relativo a la celda de la cuadrícula a la que está asociada.
  • y, posición y del centro del rectángulo delimitador relativo a la celda de la cuadrícula a la que está asociada.
  • w, ancho del rectángulo delimitador.
  • h, altura del rectángulo delimitador.
  • o, valor de confianza de que existe un objeto dentro del rectángulo delimitador, también conocido como puntuación de calidad de objeto.
  • p1-p20, probabilidades de clase para cada una de las 20 clases previstas por el modelo.

En total, los 25 elementos que describen cada uno de los 5 rectángulos delimitadores constituyen los 125 elementos contenidos en cada celda de la cuadrícula.

La salida generada por el modelo ONNX entrenado previamente es una matriz float de longitud 21125, que representa los elementos de un tensor con dimensiones 125 x 13 x 13. Para transformar las predicciones generadas por el modelo en un tensor, se requiere algún trabajo posterior al procesamiento. Para ello, cree un conjunto de clases que ayuden a analizar la salida.

Agregue un nuevo directorio al proyecto para organizar el conjunto de clases de analizador.

  1. En el Explorador de soluciones, haga clic con el botón derecho en el proyecto y, luego, seleccione Agregar>Nueva carpeta. Cuando la nueva carpeta aparezca en el Explorador de soluciones, asígnele el nombre "YoloParser".

Crear rectángulos delimitadores y dimensiones

La salida de datos del modelo contiene las coordenadas y las dimensiones de los rectángulos delimitadores de los objetos dentro de la imagen. Cree una clase base para las dimensiones.

  1. En el Explorador de soluciones, haga clic con el botón derecho en el directorio YoloParser y luego seleccione Agregar>Nuevo elemento.

  2. En el cuadro de diálogo Agregar nuevo elemento, seleccione Clase y cambie el campo Nombre a DimensionsBase.cs. A continuación, seleccione el botón Agregar.

    Se abre el archivo DimensionsBase.cs en el editor de código. Quite todas las instrucciones using y la definición de clase existente.

    Agregue el código siguiente para la clase DimensionsBase al archivo 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 tiene las siguientes propiedades float:

    • X contiene la posición del objeto en el eje x.
    • Y contiene la posición del objeto en el eje y.
    • Height contiene la altura del objeto.
    • Width contiene el ancho del objeto.

A continuación, cree una clase para los rectángulos delimitadores.

  1. En el Explorador de soluciones, haga clic con el botón derecho en el directorio YoloParser y luego seleccione Agregar>Nuevo elemento.

  2. En el cuadro de diálogo Agregar nuevo elemento, seleccione Clase y cambie el campo Nombre a YoloBoundingBox.cs. A continuación, seleccione el botón Agregar.

    Se abre el archivo YoloBoundingBox.cs en el editor de código. Agregue la instrucción using siguiente al principio del archivo YoloBoundingBox.cs:

    using System.Drawing;
    

    Justo encima de la definición de clase existente, agregue una nueva definición de clase denominada BoundingBoxDimensions que herede de la clase DimensionsBase para que contenga las dimensiones del rectángulo delimitador correspondiente.

    public class BoundingBoxDimensions : DimensionsBase { }
    

    Quite la definición de clase YoloBoundingBox existente y agregue el código siguiente para la clase YoloBoundingBox al archivo 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 tiene las siguientes propiedades:

    • Dimensions contiene las dimensiones del rectángulo delimitador.
    • Label contiene la clase de objeto detectado en el rectángulo delimitador.
    • Confidence contiene la confianza de la clase.
    • Rect contiene la representación del rectángulo de las dimensiones del rectángulo delimitador.
    • BoxColor contiene el color asociado a la clase correspondiente usada para dibujar en la imagen.

Crear el analizador

Ahora que se han creado las clases para las dimensiones y los rectángulos delimitadores, es el momento de crear el analizador.

  1. En el Explorador de soluciones, haga clic con el botón derecho en el directorio YoloParser y luego seleccione Agregar>Nuevo elemento.

  2. En el cuadro de diálogo Agregar nuevo elemento, seleccione Clase y cambie el campo Nombre a YoloOutputParser.cs. A continuación, seleccione el botón Agregar.

    Se abre el archivo YoloOutputParser.cs en el editor de código. Agregue la instrucción using siguiente al principio del archivo YoloOutputParser.cs:

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

    Dentro de la definición de clase YoloOutputParser existente, agregue una clase anidada que contenga las dimensiones de cada una de las celdas de la imagen. Agregue el código siguiente para la clase CellDimensions que hereda de la clase DimensionsBase en la parte superior de la definición de la clase YoloOutputParser.

    class CellDimensions : DimensionsBase { }
    
  3. Dentro de la definición de la clase YoloOutputParser, agregue la constante y los campos siguientes.

    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 es el número de filas de la cuadrícula en la que se divide la imagen.
    • COL_COUNT es el número de columnas de la cuadrícula en la que se divide la imagen.
    • CHANNEL_COUNT es el número total de valores contenidos en una celda de la cuadrícula.
    • BOXES_PER_CELL es el número de rectángulos delimitadores de una celda.
    • BOX_INFO_FEATURE_COUNT es el número de características contenidas en un rectángulo (x,y,altura,ancho,confianza).
    • CLASS_COUNT es el número de predicciones de clase que contiene cada rectángulo delimitador.
    • CELL_WIDTH es el ancho de una celda de la cuadrícula de imagen.
    • CELL_HEIGHT es la altura de una celda de la cuadrícula de imagen.
    • channelStride es la posición inicial de la celda actual en la cuadrícula.

    Cuando el modelo realiza una predicción, también conocida como puntuación, divide la imagen de entrada 416px x 416px en una cuadrícula de celdas del tamaño de 13 x 13. El contenido de cada celda mide 32px x 32px. Dentro de cada celda, hay 5 rectángulos delimitadores, donde cada uno contiene 5 características (x,y,ancho,altura,confianza). Además, cada rectángulo delimitador contiene la probabilidad de cada una de las clases, que, en este caso, es 20. Por lo tanto, cada celda contiene 125 piezas de información (5 características + 20 probabilidades de clase).

Cree una lista de delimitadores a continuación de channelStride para los 5 rectángulos delimitadores:

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

Los delimitadores son proporciones predefinidas de altura y ancho de los rectángulos delimitadores. La mayoría de los objetos o clases detectados por un modelo tienen relaciones similares. Esto resulta útil cuando se trata de crear rectángulos delimitadores. En lugar de predecir los rectángulos delimitadores, se calcula el desplazamiento de las dimensiones predefinidas; por lo que se reducen los cálculos necesarios para predecir el rectángulo delimitador. Normalmente, estas proporciones de delimitador se calculan en función del conjunto de datos usado. En este caso, dado que el conjunto de datos es conocido y los valores se han calculado previamente, los delimitadores se pueden codificar de forma rígida.

A continuación, defina las etiquetas o clases que el modelo predecirá. Este modelo predice 20 clases, que es un subconjunto del número total de clases que predice el modelo de YOLOv2 original.

Agregue la lista de etiquetas debajo 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"
};

Hay colores asociados a cada una de las clases. Asigne los colores de clase debajo 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
};

Crear funciones auxiliares

La fase posterior al procesamiento implica una serie de pasos. Para ayudar con esto, se pueden emplear varios métodos auxiliares.

Los métodos auxiliares que usa el analizador son los siguientes:

  • Sigmoid aplica la función sigmoidea que da como resultado un número entre 0 y 1.
  • Softmax normaliza un vector de entrada en una distribución de probabilidad.
  • GetOffset asigna los elementos de la salida del modelo unidimensional a la posición correspondiente en un tensor de 125 x 13 x 13.
  • ExtractBoundingBoxes extrae las dimensiones del rectángulo delimitador mediante el método GetOffset de la salida del modelo.
  • GetConfidence extrae el valor de confianza que indica cómo de seguro está el modelo de que ha detectado un objeto y usa la función Sigmoid para convertirlo en porcentaje.
  • MapBoundingBoxToCell usa las dimensiones del rectángulo delimitador y las asigna a su celda correspondiente dentro de la imagen.
  • ExtractClasses extrae las predicciones de clase para el rectángulo delimitador de la salida del modelo usando el método GetOffset y las convierte en una distribución de probabilidad mediante el método Softmax.
  • GetTopResult selecciona la clase de la lista de clases predichas con la probabilidad más alta.
  • IntersectionOverUnion filtra los rectángulos delimitadores superpuestos con probabilidades más bajas.

Agregue el código para todos los métodos auxiliares debajo de la 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);
}

Una vez que haya definido todos los métodos auxiliares, es el momento de usarlos para procesar la salida del modelo.

Debajo del método IntersectionOverUnion, cree el método ParseOutputs para procesar la salida generada por el modelo.

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

}

Cree una lista para almacenar los rectángulos delimitadores y defina las variables dentro del método ParseOutputs.

var boxes = new List<YoloBoundingBox>();

Cada imagen se divide en una cuadrícula de 13 x 13 celdas. Cada celda contiene cinco rectángulos delimitadores. Debajo de la variable boxes, agregue código para procesar todos los rectángulos de cada una de las celdas.

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 del bucle más interno, calcule la posición inicial del rectángulo actual dentro de la salida del modelo unidimensional.

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

Justo debajo de eso, use el método ExtractBoundingBoxDimensions para obtener las dimensiones del rectángulo delimitador actual.

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

Luego, use el método GetConfidence para obtener la confianza del rectángulo delimitador actual.

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

Después de eso, use el método MapBoundingBoxToCell para asignar el rectángulo delimitador actual a la celda actual que se está procesando.

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

Antes de realizar cualquier procesamiento adicional, compruebe si el valor de confianza es mayor que el umbral proporcionado. Si no es así, procese el siguiente rectángulo delimitador.

if (confidence < threshold)
    continue;

De lo contrario, continúe procesando la salida. El paso siguiente consiste en obtener la distribución de probabilidad de las clases previstas para el rectángulo delimitador actual mediante el método ExtractClasses.

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

A continuación, use el método GetTopResult para obtener el valor y el índice de la clase con la probabilidad más alta para el rectángulo actual y calcular su puntuación.

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

Use topScore para conservar una vez más solo aquellos rectángulos delimitadores que estén por encima del umbral especificado.

if (topScore < threshold)
    continue;

Por último, si el rectángulo delimitador actual supera el umbral, cree un nuevo objeto BoundingBox y agréguelo a la 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]
});

Una vez que se hayan procesado todas las celdas de la imagen, devuelva la lista boxes. Agregue la siguiente instrucción return debajo del bucle for más exterior en el método ParseOutputs.

return boxes;

Filtrar los rectángulos superpuestos

Ahora que todos los rectángulos delimitadores de gran confianza se han extraído de la salida del modelo, es necesario realizar un filtrado adicional para quitar las imágenes superpuestas. Agregue un método denominado FilterBoundingBoxes debajo del método ParseOutputs:

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

}

Dentro del método FilterBoundingBoxes, empiece por crear una matriz igual al tamaño de los rectángulos detectados, y marque todas las ranuras como activas o listas para su procesamiento.

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

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

Después, ordene la lista que contiene los rectángulos delimitadores en orden descendente en función de la confianza.

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

Luego de eso, cree una lista que contenga los resultados filtrados.

var results = new List<YoloBoundingBox>();

Comience a procesar cada rectángulo delimitador iterando cada uno de los rectángulos delimitadores.

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

}

Dentro de este bucle for, compruebe si se puede procesar el rectángulo delimitador actual.

if (isActiveBoxes[i])
{

}

Si es así, agregue el rectángulo delimitador a la lista de resultados. Si los resultados superan el límite de rectángulos especificado que se van a extraer, interrumpa el bucle. Agregue el código siguiente dentro de la instrucción if.

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

if (results.Count >= limit)
    break;

De lo contrario, fíjese en los rectángulos delimitadores adyacentes. Agregue el código siguiente debajo de la comprobación del límite del rectángulo.

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

}

Al igual que el primer rectángulo, si el rectángulo adyacente está activo o listo para procesarse, use el método IntersectionOverUnion para comprobar si el primer rectángulo y el segundo rectángulo superan el umbral especificado. Agregue el código siguiente a su bucle for más interno.

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

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

        if (activeCount <= 0)
            break;
    }
}

Fuera del bucle for más interno que comprueba los rectángulos delimitadores adyacentes, compruebe si hay rectángulos delimitadores restantes para procesar. En caso contrario, interrumpa el bucle for externo.

if (activeCount <= 0)
    break;

Por último, fuera del bucle for inicial del método FilterBoundingBoxes, devuelva los resultados:

return results;

Perfecto. Ahora es el momento de usar este código junto con el modelo para la puntuación.

Uso del modelo para puntuación

Al igual que con el procesamiento posterior, hay algunos pasos en el procedimiento de puntuación. Para ayudarlo con esto, agregue al proyecto una clase que contendrá la lógica de puntuación.

  1. En el Explorador de soluciones, haga clic con el botón derecho en el proyecto y, a continuación, seleccione Agregar>Nuevo elemento.

  2. En el cuadro de diálogo Agregar nuevo elemento, seleccione Clase y cambie el campo Nombre a OnnxModelScorer.cs. A continuación, seleccione el botón Agregar.

    El archivo OnnxModelScorer.cs se abre en el editor de código. Agregue las instrucciones using siguientes al principio del archivo 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 de la definición de la clase OnnxModelScorer, agregue las variables siguientes.

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

    Justo debajo de eso, cree un constructor para la clase OnnxModelScorerque inicializará las variables definidas anteriormente.

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

    Una vez creado el constructor, defina un par de estructuras que contengan variables relacionadas con la configuración de la imagen y el modelo. Cree una estructura denominada ImageNetSettings para que contenga la altura y el ancho esperados como entrada para el modelo.

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

    Después, cree otra estructura denominada TinyYoloModelSettings que contenga los nombres de las capas de entrada y salida del modelo. Para visualizar el nombre de las capas de entrada y salida del modelo, puede usar una herramienta 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";
    }
    

    A continuación, cree el primer conjunto de métodos que usará para la puntuación. Cree el método LoadModel dentro de la clase OnnxModelScorer.

    private ITransformer LoadModel(string modelLocation)
    {
    
    }
    

    Dentro del método LoadModel, agregue el código siguiente para el registro.

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

    Es necesario que las canalizaciones ML.NET conozcan el esquema de datos en el que van a funcionar cuando se llame al método Fit. En este caso, se usará un proceso similar al de entrenamiento. Sin embargo, dado que no se está produciendo ningún entrenamiento real, es aceptable usar un IDataView vacío. Cree un nuevo IDataView para la canalización a partir de una lista vacía.

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

    Luego de eso, defina la canalización. La canalización constará de cuatro transformaciones.

    • LoadImages carga la imagen como mapa de bits.
    • ResizeImages cambia la escala de la imagen al tamaño especificado (en este caso, 416 x 416).
    • ExtractPixels cambia la representación en píxeles de la imagen de un mapa de bits a un vector numérico.
    • ApplyOnnxModel carga el modelo de ONNX y lo usa para puntuar los datos proporcionados.

    Defina la canalización en el método LoadModel debajo de la variable 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 }));
    

    Ahora es el momento de crear una instancia del modelo para la puntuación. Llame al método Fit en la canalización y devuélvalo para seguir procesándolo.

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

Una vez que el modelo esté cargado, podrá usarse para hacer predicciones. Para facilitar este proceso, cree un método denominado PredictDataUsingModel debajo del método LoadModel.

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

}

Dentro de PredictDataUsingModel, agregue el código siguiente para el registro.

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

A continuación, use el método Transform para puntuar los datos.

IDataView scoredData = model.Transform(testData);

Extraiga las probabilidades previstas y devuélvalas para su procesamiento adicional.

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

return probabilities;

Ahora que ambos pasos están configurados, combínelos en un único método. Debajo del método PredictDataUsingModel, agregue un nuevo método denominado Score.

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

    return PredictDataUsingModel(data, model);
}

Ya casi lo tenemos. Ahora es el momento de ponerlo todo en práctica.

Detectar objetos

Ahora que ha completado toda la configuración, es el momento de detectar algunos objetos.

Resultados del modelo de puntuación y análisis

Debajo de la creación de la variable mlContext, agregue una instrucción try-catch.

try
{

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

Dentro del bloque try, empiece a implementar la lógica de detección de objetos. En primer lugar, cargue los datos en un IDataView.

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

Luego, cree una instancia de OnnxModelScorer y úsela para puntuar los datos cargados.

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

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

Ahora es el momento de completar el paso de procesamiento posterior. Cree una instancia de YoloOutputParser y úsela para procesar la salida del modelo.

YoloOutputParser parser = new YoloOutputParser();

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

Una vez que se haya procesado la salida del modelo, es el momento de dibujar los rectángulos delimitadores en las imágenes.

Visualización de predicciones

Una vez que el modelo ha puntuado las imágenes y se han procesado los resultados, hay que dibujar los rectángulos delimitadores en la imagen. Para ello, agregue un método denominado DrawBoundingBox debajo del método GetAbsolutePath dentro de Program.cs.

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

}

En primer lugar, cargue la imagen y obtenga las dimensiones de altura y ancho en el método DrawBoundingBox.

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

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

A continuación, cree un bucle for-each para iterar por cada uno de los rectángulos delimitadores detectados por el modelo.

foreach (var box in filteredBoundingBoxes)
{

}

Dentro del bucle for-each, obtenga las dimensiones del rectángulo delimitador.

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

Dado que las dimensiones del rectángulo delimitador corresponden a la entrada del modelo de 416 x 416, escale las dimensiones del rectángulo delimitador para que coincidan con el tamaño real de la imagen.

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;

Luego, defina una plantilla para el texto que aparecerá sobre cada rectángulo delimitador. El texto contendrá la clase del objeto dentro del rectángulo delimitador correspondiente, así como la confianza.

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

Para dibujar en la imagen, conviértala en un objeto Graphics.

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

}

Dentro del bloque de código using, ajuste la configuración del objeto Graphics del gráfico.

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

Luego de eso, establezca las opciones de fuente y color para el texto y el rectángulo delimitador.

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

Cree y rellene un rectángulo sobre el rectángulo delimitador para que contenga el texto mediante el método FillRectangle. Esto le ayudará a contrastar el texto y mejorar la legibilidad.

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

A continuación, dibuje el texto y el rectángulo delimitador en la imagen con los métodos DrawString y DrawRectangle.

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

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

Fuera del bucle for-each, agregue código para guardar las imágenes en outputFolder.

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

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

Para recibir comentarios adicionales de que la aplicación está haciendo predicciones según lo esperado en tiempo de ejecución, agregue un método denominado LogDetectedObjects debajo del método DrawBoundingBox en el archivo Program.cs a fin de generar los objetos detectados en la consola.

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

Ahora que tiene métodos auxiliares para crear comentarios visuales a partir de las predicciones, agregue un bucle for para iterar en cada una de las imágenes puntuadas.

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

}

Dentro del bucle for, obtenga el nombre del archivo de imagen y los rectángulos delimitadores asociados a él.

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

A continuación, use el método DrawBoundingBox para dibujar los rectángulos delimitadores en la imagen.

DrawBoundingBox(imagesFolder, outputFolder, imageFileName, detectedObjects);

Por último, use el método LogDetectedObjects para generar predicciones en la consola.

LogDetectedObjects(imageFileName, detectedObjects);

Después de la instrucción try-catch, agregue lógica adicional para indicar que el proceso ha terminado de ejecutarse.

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

Ya está.

Resultados

Después de seguir los pasos anteriores, ejecute la aplicación de consola (Ctrl + F5). Los resultados deberían ser similares a la salida siguiente. Es posible que vea advertencias o mensajes de procesamiento, si bien se han quitado de los resultados siguientes para mayor claridad.

=====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 las imágenes con rectángulos delimitadores, desplácese hasta el directorio assets/images/output/. A continuación se muestra un ejemplo de una de las imágenes procesadas.

Sample processed image of a dining room

Felicidades. Ha creado correctamente un modelo de Machine Learning para la detección de objetos al reutilizar un modelo de ONNX entrenado previamente en ML.NET.

Puede encontrar el código fuente para este tutorial en el repositorio dotnet/machinelearning-samples.

En este tutorial ha aprendido a:

  • Entender el problema
  • Conocer qué es ONNX y cómo funciona con ML.NET
  • Entender el modelo
  • Volver a usar el modelo entrenado previamente
  • Detectar objetos con un modelo cargado

Consulte el repositorio de GitHub con ejemplos de Machine Learning para explorar un ejemplo expandido de detección de objetos.