Tutorial en papel de YOLOv3: incluso mejor, pero no tanto

para ser el algoritmo de detección de objetos de última generación, pareció quedar obsoleto gracias a la aparición de otros métodos como SSD (Single Shot Multibox Detector), DSSD (Deconvolutional Single Shot Detector) y RetinaNet. Finalmente, después de dos años desde la introducción de YOLOv2, los autores decidieron mejorar el algoritmo y finalmente crearon la próxima versión de YOLO, informada en un artículo titulado "YOLOv3: An Incremental Improvement".[1]. Como sugiere el título, de hecho no hubo muchas cosas que los autores mejoraron en YOLOv2 en términos del algoritmo subyacente. Pero bueno, cuando se trata de rendimiento, en realidad parece bastante impresionante.

En este artículo voy a hablar sobre las modificaciones que los autores hicieron en YOLOv2 para crear YOLOv3 y cómo implementar la arquitectura del modelo desde cero con PyTorch. Te recomiendo mucho que leas mi artículo anterior sobre YOLOv1 [2, 3] y YOLOv2[4]antes de esta, a menos que ya tengas una base sólida sobre cómo funcionan estas dos versiones anteriores de YOLO.

¿Qué hace que YOLOv3 sea mejor que YOLOv2?

El Darknet vainilla-53

La modificación que realizaron los autores estuvo relacionada principalmente con la arquitectura, en la que propusieron un modelo de red troncal denominado Darknet-53. Vea la estructura detallada de esta red en la Figura 1. Como sugiere el nombre, este modelo es una mejora del Darknet-19 utilizado en YOLOv2. Si cuenta la cantidad de capas en Darknet-53, encontrará que esta red consta de 52 capas convolucionales y una única capa completamente conectada al final. Tenga en cuenta que más adelante, cuando lo implementemos en YOLOv3, lo alimentaremos con imágenes de tamaño 416 × 416 en lugar de 256 × 256 como se muestra en la figura.

Figura 1. La arquitectura Vanilla Darknet-53[1].

Si está familiarizado con Darknet-19, debe recordar que realiza una reducción espacial utilizando operaciones de maxpooling después de cada pila de varias capas convolucionales. En Darknet-53, los autores reemplazaron estas operaciones de agrupación con convoluciones de paso 2. Esto se hizo esencialmente porque la capa de agrupación máxima ignora por completo los números no máximos, lo que nos hace perder mucha información contenida en los píxeles de menor intensidad. De hecho, podemos utilizar la agrupación promedio como alternativa, pero en teoría, este enfoque tampoco será óptimo porque todos los píxeles dentro de la región pequeña tienen el mismo peso. Entonces, como solución, los autores decidieron usar una capa de convolución con un paso de 2, lo que al hacerlo el modelo podrá reducir la resolución de la imagen mientras captura información espacial con ponderaciones específicas. Puede ver la ilustración de esto en la Figura 2 a continuación.

Figura 2. En qué se diferencian entre sí maxpooling, Average-pooling y convolución con stride 2[5].

A continuación, la columna vertebral de esta versión de YOLO ahora está equipada con bloques residuales cuya idea se originó en ResNet. Una cosa que quiero enfatizar con respecto a nuestra implementación es la función de activación dentro del bloque residual. Puede ver en la Figura 3 a continuación que, según el artículo original de ResNet, la segunda función de activación se coloca después de la suma de elementos. Sin embargo, según los otros tutoriales que leí [6, 7], descubrí que en el caso de YOLOv3 la segunda función de activación se coloca justo después de la capa de peso (antes de la suma). Entonces, más adelante en la implementación, decidí seguir la guía de estos tutoriales ya que el artículo de YOLOv3 no da ninguna explicación al respecto.

Figura 3. Un bloque residual[8]

Darknet-53 con cabezales de detección

Tenga en cuenta que la arquitectura de la Figura 1 solo está destinada a la clasificación. Por lo tanto, debemos reemplazar todo después del último bloque residual si queremos que sea compatible para las tareas de detección. Nuevamente, el artículo original de YOLOv3 tampoco proporciona la guía de implementación detallada, por lo que decidí buscarla y finalmente obtuve una del artículo al que se hace referencia como[9]. Vuelvo a dibujar la ilustración de ese documento para que la arquitectura se vea más clara, como se muestra en la Figura 4 a continuación.

Figura 4. La arquitectura YOLOv3[5].

En realidad, hay muchas cosas que explicar con respecto a la arquitectura anterior. Ahora comencemos por la parte a la que me refiero como cabezales de detección. A diferencia de las versiones anteriores de YOLO que dependían de un solo cabezal, aquí en YOLOv3 tenemos 2 cabezales adicionales. Por lo tanto, luego tendremos 3 tensores de predicción para cada imagen de entrada. Estos tres cabezales de detección tienen diferentes especializaciones: el cabezal más a la izquierda (13×13) es el responsable de detectar objetos grandes, el cabezal del medio (26×26) es para detectar objetos de tamaño mediano y el de la derecha (52×52) se usa para detectar objetos de tamaño pequeño. Podemos pensar en el tensor de 52 × 52 como el mapa de características que contiene la representación detallada de una imagen, por lo que es adecuado para detectar objetos pequeños. Por el contrario, el tensor de predicción de 13 × 13 está destinado a detectar objetos grandes debido a su menor resolución espacial, que es eficaz para capturar la forma general de un objeto.

Aún con el cabezal de detección, también puede ver en la Figura 4 que los tres tensores de predicción tienen 255 canales. Para entender de dónde viene este número, primero debemos saber que cada cabezal de detección tiene 3 casillas anteriores. Siguiendo la regla dada en YOLOv2, cada uno de estos cuadros anteriores está configurado de manera que pueda predecir su propia categoría de objeto de forma independiente. Con este mecanismo, el vector de características de cada celda de la cuadrícula se puede obtener calculando B×(5+C), donde B es el número de cuadros anteriores, C es el número de clases de objetos y 5 es la confianza de xywh y del cuadro delimitador (también conocido como objetividad). En el caso de YOLOv3, cada cabezal de detección tiene 3 cuadros anteriores y 80 clases, suponiendo que lo entrenemos en un conjunto de datos COCO de 80 clases. Al conectar estos números a la fórmula, obtenemos 3×(5+80)=255 valores de predicción para una sola celda de la cuadrícula.

De hecho, el uso de un mecanismo de cabezales múltiples como este permite que el modelo detecte más objetos en comparación con las versiones anteriores de YOLO. Anteriormente en YOLOv1, dado que una imagen se divide en celdas de cuadrícula de 7 × 7 y cada una de ellas puede predecir 2 cuadros delimitadores, hay 98 objetos posibles de detectar. Mientras tanto, en YOLOv2, una imagen se divide en celdas de cuadrícula de 13 × 13 en las que una sola celda es capaz de generar 5 cuadros delimitadores, lo que hace que YOLOv2 pueda detectar hasta 845 objetos dentro de una sola imagen. Básicamente, esto permite que YOLOv2 tenga una mejor recuperación que YOLOv1. En teoría, YOLOv3 es potencialmente capaz de lograr una recuperación aún mayor, especialmente cuando se prueba en una imagen que contiene muchos objetos gracias a la mayor cantidad de detecciones posibles. Podemos calcular el número máximo de cuadros delimitadores para una sola imagen en YOLOv3 calculando (13×13×3) + (26×26×3) + (52×52×3) = 507 + 2028 + 8112 = 10647, donde 13×13, 26×26 y 52×25 son el número de celdas de la cuadrícula dentro de cada tensor de predicción, mientras que 3 es el número de cuadros delimitadores anteriores. cuadros que tiene una sola celda de la cuadrícula.

También podemos ver en la Figura 4 que hay dos pasos de concatenación incorporados en la red, es decir, entre la arquitectura Darknet-53 original y los cabezales de detección. El objetivo de estos pasos es combinar información de la capa más profunda con la de la capa menos profunda. Combinar información de diferentes profundidades como esta es importante porque cuando se trata de detectar objetos más pequeños, necesitamos tanto información espacial detallada (contenida en la capa menos profunda) como una mejor información semántica (contenida en la capa más profunda). Tenga en cuenta que el mapa de características de la capa más profunda tiene una dimensión espacial más pequeña, por lo que debemos expandirlo antes de realizar la concatenación. Esta es esencialmente la razón por la que necesitamos colocar una capa de muestreo superior justo antes de realizar la concatenación.

Clasificación de etiquetas múltiples

Además de la arquitectura, los autores también cambiaron el mecanismo de etiquetado de clases. En lugar de utilizar un paradigma de clasificación multiclase estándar, propusieron utilizar la denominada clasificación multietiqueta. Si aún no está familiarizado con él, este es básicamente un método en el que a una imagen se le pueden asignar varias etiquetas a la vez. Eche un vistazo a la Figura 5 a continuación para comprender mejor esta idea. En este ejemplo, la imagen de la izquierda pertenece a la clase persona, atleta, corredor y hombre simultáneamente. Más adelante, también se espera que YOLOv3 pueda realizar múltiples predicciones de clases sobre el mismo objeto detectado.

Figura 5. Imagen con múltiples etiquetas[10].

Para que el modelo prediga múltiples etiquetas, debemos tratar cada salida de predicción de clase como un clasificador binario independiente. Mire la Figura 6 a continuación para ver en qué se diferencia la clasificación multiclase de la clasificación multietiqueta. La ilustración de la izquierda es una condición cuando utilizamos un mecanismo típico de clasificación multiclase. Aquí puede ver que las probabilidades de todas las clases suman 1 gracias a la naturaleza de la función de activación softmax dentro de la capa de salida. En este ejemplo, dado que la clase camel se predice con la mayor probabilidad, entonces la predicción final sería camel independientemente de qué tan alta sea la confianza de predicción de las otras clases.

Por otro lado, si usamos clasificación multietiqueta, existe la posibilidad de que la suma de todas las probabilidades de predicción de clase sea mayor que 1 porque usamos la función de activación sigmoidea que por naturaleza no restringe la suma de todas las puntuaciones de confianza de predicción a 1. Gracias a esta razón, más adelante en la implementación podemos simplemente aplicar un umbral específico para considerar una clase predicha. En el siguiente ejemplo, si asumimos que el umbral es 0,7, entonces la imagen se predecirá como cactus y camello.

Figura 6. Clasificación multiclase versus multietiqueta[10].

Función de pérdida modificada

Otra modificación que hicieron los autores estuvo relacionada con la función de pérdida. Ahora observe la función de pérdida de YOLOv1 en la Figura 7 a continuación. Como repaso, las filas 1.ª y 2.ª son responsables de calcular la pérdida del cuadro delimitador, las filas 3.ª y 4.ª son para la pérdida de confianza de la objetividad y la 5.ª fila es para calcular la pérdida de clasificación. Recuerde que en YOLOv1 los autores usaron SSE (Suma de errores al cuadrado) en estas cinco filas para simplificar las cosas.

Figura 7. La función de pérdida de YOLOv1 [5, 11].

En YOLOv3, los autores decidieron reemplazar la pérdida de objetividad (las filas 3 y 4) con entropía cruzada binaria, considerando que las predicciones correspondientes a esta parte son solo 1 o 0, es decir, si hay un punto medio del objeto o no. Por lo tanto, tiene más sentido tratar esto como una clasificación binaria y no como un problema de regresión.

La entropía cruzada binaria también se utilizará en la pérdida de clasificación (quinta fila). Esto se debe esencialmente a que utilizamos el mecanismo de clasificación de etiquetas múltiples que analizamos anteriormente, donde tratamos cada una de las neuronas de salida como un clasificador binario independiente. Recuerde que si tuviéramos que realizar una tarea de clasificación estándar, normalmente necesitaríamos establecer la función de pérdida en entropía cruzada categórica.

A continuación se muestra cómo se ve la función de pérdida después de reemplazar el SSE con entropía cruzada binaria para las partes de objetividad (verde) y clasificación de etiquetas múltiples (azul). Tenga en cuenta que esta ecuación se crea en base al tutorial de YouTube que vi en el número de referencia.[12]porque, nuevamente, los autores no proporcionan explícitamente la función de pérdida final en el artículo.

Figura 8. La función de pérdida de YOLOv3 [5, 12].

Algunos resultados experimentales

Con todas las modificaciones comentadas anteriormente, los autores descubrieron que la mejora en el rendimiento es bastante impresionante. El primer resultado experimental que quiero mostrarles está relacionado con el rendimiento del modelo principal en la clasificación de imágenes en el conjunto de datos ImageNet. Puede ver en la Figura 9 a continuación que la mejora de Darknet-19 (YOLOv2) a Darknet-53 (YOLOv3) es bastante significativa en términos tanto de la precisión del top 1 (74,1 a 77,2) como de la precisión del top 5 (91,8 a 93,8). Es necesario reconocer que ResNet-101 y ResNet-152 también funcionan tan bien como Darknet-53 en precisión, pero si comparamos los FPS (medidos en Nvidia Titan X), podemos ver que Darknet-53 es mucho más rápido que ambas variantes de ResNet.

Figura 9. Rendimiento de diferentes redes troncales en el conjunto de datos de clasificación de ImageNet[1].

También se podría observar un comportamiento similar en la tarea de detección de objetos, donde se ve en la Figura 10 que todas las variantes de YOLOv3 obtuvieron con éxito el tiempo de cálculo más rápido entre todos los demás métodos a pesar de no tener la mejor precisión. Puede ver en la figura que la variante YOLOv3 más grande es casi 4 veces más rápida que la variante RetinaNet más grande (51 ms frente a 198 ms). Además, la variante YOLOv3 más grande ya supera el mAP de la variante RetinaNet más pequeña (33,0 frente a 32,5) y al mismo tiempo tiene un tiempo de inferencia más rápido (51 ms frente a 73 ms). Estos resultados experimentales demuestran esencialmente que YOLOv3 se convirtió en el modelo de detección de objetos de última generación en términos de velocidad computacional en ese momento.

Figura 10. Rendimiento de YOLOv3 en comparación con otros métodos de detección de objetos[10].

Implementación de la arquitectura YOLOv3

Como ya hemos discutido prácticamente todo sobre la teoría detrás de YOLOv3, ahora podemos comenzar a implementar la arquitectura desde cero. En el bloque de código 1 a continuación, importo el módulo de antorcha y su submódulo nn. Aquí también inicializo las variables NUM_PRIORS y NUM_CLASS, en las que estas dos corresponden a la cantidad de cuadros anteriores dentro de cada celda de la cuadrícula y la cantidad de clases de objetos en el conjunto de datos, respectivamente.

# Bloque de código 1 importar antorcha importar antorcha.nn como nn NUM_PRIORS = 3 NUM_CLASS = 80

Implementación de bloques convolucionales

Lo que voy a implementar primero es el bloque de construcción principal de la red, al que me refiero como bloque convolucional, como se ve en Codeblock 2. La estructura de este bloque es bastante similar a la utilizada en YOLOv2, donde sigue el patrón Conv-BN-Leaky ReLU. Cuando usamos este tipo de estructura, no olvide establecer el parámetro de polarización de la capa conv en Falso (en la línea #(1)) porque usar el término de polarización es algo inútil si colocamos directamente una capa de normalización por lotes justo después. Aquí también configuro el relleno de la capa conv de modo que se establezca automáticamente en 1 siempre que el tamaño del kernel sea 3×3 o 0 siempre que usemos el kernel 1×1 (#(2)). A continuación, como conv, bn y Leaky_relu se han inicializado, podemos simplemente conectarlos todos usando el código escrito dentro del método forward() (#(3)).

# Bloque de código 2 clase Convolucional(nn.Module): def __init__(self, in_channels, out_channels, kernel_size, stride=1): super().__init__() self.conv = nn.Conv2d(in_channels=in_channels, out_channels=out_channels, kernel_size=kernel_size, stride=stride, sesgo=False, #(1) padding=1 if kernel_size==3 else 0) #(2) self.bn = nn.BatchNorm2d(num_features=out_channels) self.leaky_relu = nn.LeakyReLU(negative_slope=0.1) def forward(self, x): #(3) print(f'originalt: {x.size()}') x = self.conv(x) print(f'after convt: {x.size()}') x = self.bn(x) print(f'después de bnt: {x.size()}') x = self.leaky_relu(x) print(f'después de relu con fugas: {x.size()}') return x

Solo queremos asegurarnos de que nuestro bloque de construcción principal funcione correctamente, lo probaremos simulando el primer bloque convolucional en la Figura 1. Recuerde que dado que YOLOv3 toma una imagen de tamaño 416 × 416 como entrada, aquí en Codeblock 3 creo un tensor ficticio de esa forma para simular una imagen que pasa a través de esa capa. Además, tenga en cuenta que aquí dejo el paso predeterminado (1) porque en este punto no queremos realizar una reducción de resolución espacial.

# Bloque de código 3 convolucional = Convolucional(in_channels=3, out_channels=32, kernel_size=3) x = torch.randn(1, 3, 416, 416) out = convolucional(x) # Bloque de código 3 Salida original: torch.Size([1, 3, 416, 416]) después de conv: torch.Size([1, 32, 416, 416]) después de bn: antorcha.Tamaño([1, 32, 416, 416]) después de relu con fugas: antorcha.Tamaño([1, 32, 416, 416])

Ahora probemos nuevamente nuestro bloque convolucional, pero esta vez estableceré el paso en 2 para simular el segundo bloque convolucional de la arquitectura. Podemos ver en el resultado a continuación que la dimensión espacial se reduce a la mitad de 416 × 416 a 208 × 208, lo que indica que este enfoque es un reemplazo válido para las capas de maxpooling que teníamos anteriormente en YOLOv1 y YOLOv2.

# Bloque de código 4 convolucional = Convolucional(in_channels=32, out_channels=64, kernel_size=3, stride=2) x = torch.randn(1, 32, 416, 416) out = convolucional(x) # Bloque de código 4 Salida original: torch.Size([1, 32, 416, 416]) después de conv: torch.Size([1, 64, 208, 208]) después de bn: antorcha.Tamaño ([1, 64, 208, 208]) después de relu con fugas: antorcha.Tamaño ([1, 64, 208, 208])

Implementación de bloques residuales

Una vez terminado el bloque convolucional, lo que voy a hacer ahora es implementar el siguiente bloque de construcción: Residual. Este bloque generalmente sigue la estructura que mostré en la Figura 3, donde consta de una conexión residual que salta dos bloques convolucionales. Eche un vistazo al Codeblock 5 a continuación para ver cómo lo implemento.

Las dos capas de convolución siguen el patrón de la Figura 1, donde el primer convolucional reduce a la mitad el número de canales (#(1)), que luego será duplicado nuevamente por el segundo convolucional (#(3)). Aquí también debe tener en cuenta que la primera convolución usa un núcleo 1×1 (#(2)) mientras que la segunda usa 3×3 (#(4)). A continuación, lo que hacemos dentro del método forward() es simplemente conectar las dos convoluciones secuencialmente, y la salida final se suma con el tensor de entrada original (#(5)) antes de ser devuelta.

# Bloque de código 5 clase Residual(nn.Module): def __init__(self, num_channels): super().__init__() self.conv0 = Convolucional(in_channels=num_channels, out_channels=num_channels//2, #(1) kernel_size=1, #(2) stride=1) self.conv1 = Convolutional(in_channels=num_channels//2, out_channels=num_channels, #(3) kernel_size=3, #(4) stride=1) def adelante(self, x): original = x.clone() print(f'originalt: {x.size()}') x = self.conv0(x) print(f'after conv0t: {x.size()}') x = self.conv1(x) print(f'after conv1t: {x.size()}') x = x + original #(5) print(f'después de la sumat: {x.size()}') return x

Ahora probaremos el bloque Residual que acabamos de crear usando el bloque de código 6 a continuación. Aquí configuro el parámetro num_channels en 64 porque quiero simular el primer bloque residual en la arquitectura Darknet-53 (ver Figura 1).

# Bloque de código 6 residual = Residual(num_channels=64) x = torch.randn(1, 64, 208, 208) out = residual(x) # Bloque de código 6 Salida original: torch.Size([1, 64, 208, 208]) después de conv0: torch.Size([1, 32, 208, 208]) después de conv1: torch.Size([1, 64, 208, 208]) después de la suma: torch.Size([1, 64, 208, 208])

Si observa más de cerca el resultado anterior, notará que la forma de los tensores de entrada y salida es exactamente la misma. Básicamente, esto nos permite repetir múltiples bloques residuales fácilmente. En el Codeblock 7 a continuación, intento apilar 4 bloques residuales y pasar un tensor a través de ellos, simulando la última pila de bloques residuales en la arquitectura.

# Bloque de código 7 residuales = nn.ModuleList([]) for _ in range(4): residual = Residual(num_channels=1024) residuales.append(residual) x = torch.randn(1, 1024, 13, 13) for i in range(len(residuals)): x = residuales[i](x) print(f'después de residuales #{i}t: {x.size()}') # Bloque de código 7 Salida después de residuales #0: torch.Size([1, 1024, 13, 13]) después de los residuales #1: torch.Size([1, 1024, 13, 13]) después de los residuales #2: torch.Size([1, 1024, 13, 13]) después de los residuales #3: torch.Size([1, 1024, 13, 13])

Implementación de Darknet-53

Usando los bloques de construcción convolucionales y residuales que creamos anteriormente, ahora podemos construir el modelo Darknet-53. Todo lo que inicializo dentro del método __init__() a continuación se basa en la arquitectura de la Figura 1. Sin embargo, recuerde que debemos detenernos en el último bloque residual, ya que no necesitamos la agrupación promedio global ni las capas completamente conectadas. No solo eso, en las líneas marcadas con #(1) y #(2) almaceno los mapas de características intermedias en variables separadas (rama0 y rama1). Más adelante devolveremos estos mapas de características junto con la salida del flujo principal (x) (#(3)) para implementar las ramas que fluyen hacia los tres cabezales de detección.

# Codeblock 8 clase Darknet53(nn.Module): def __init__(self): super().__init__() self.convolutional0 = Convolucional(in_channels=3, out_channels=32, kernel_size=3) self.convolutional1 = Convolucional(in_channels=32, out_channels=64, kernel_size=3, stride=2) self.residuals0 = nn.ModuleList([Residual(num_channels=64) for _ in range(1)]) self.convolutional2 = Convolutional(in_channels=64, out_channels=128, kernel_size=3, stride=2) self.residuals1 = nn.ModuleList([Residual(num_channels=128) for _ in range(2)]) self.convolutional3 = Convolucional(in_channels=128, out_channels=256, kernel_size=3, stride=2) self.residuals2 = nn.ModuleList([Residual(num_channels=256) for _ in range(8)]) self.convolutional4 = Convolucional(in_channels=256, out_channels=512, kernel_size=3, stride=2) self.residuals3 = nn.ModuleList([Residual(num_channels=512) for _ in range(8)]) self.convolutional5 = Convolutional(in_channels=512, out_channels=1024, kernel_size=3, stride=2) self.residuals4 = nn.ModuleList([Residual(num_channels=1024) for _ in range(4)]) def forward(self, x): print(f'originaltt: {x.size()}n') x = self.convolutional0(x) print(f'after convolutional0t: {x.size()}') x = self.convolutional1(x) print(f'after convolutional1t: {x.size()}n') for i in range(len(self.residuals0)): x = self.residuals0[i](x) print(f'después de residuales0 #{i}t: {x.size()}') x = self.convolutional2(x) print(f'nafter convolutional2t: {x.size()}n') for i in range(len(self.residuals1)): x = self.residuals1[i](x) print(f'después de residuales1 #{i}t: {x.size()}') x = self.convolutional3(x) print(f'nafter convolutional3t: {x.size()}n') for i in range(len(self.residuals2)): x = self.residuals2[i](x) print(f'after residuales2 #{i}t: {x.size()}') branch0 = x.clone() #(1) x = self.convolutional4(x) print(f'nafter convolutional4t: {x.size()}n') for i in range(len(self.residuals3)): x = self.residuals3[i](x) print(f'after residuales3 #{i}t: {x.size()}') rama1 = x.clone() #(2) x = self.convolutional5(x) print(f'nafter convolucional5t: {x.size()}n') for i in range(len(self.residuals4)): x = self.residuals4[i](x) print(f'after residuales4 #{i}t: {x.size()}') return rama0, rama1, x #(3)

Ahora probamos nuestra clase Darknet53 ejecutando Codeblock 9 a continuación. Puede ver en el resultado resultante que todo parece funcionar correctamente ya que la forma del tensor se transforma correctamente de acuerdo con la guía en la Figura 1. Una cosa que no he mencionado antes es que esta arquitectura Darknet-53 reduce la imagen de entrada en un factor de 32. Entonces, con este factor de reducción de resolución, una imagen de entrada con una forma de 256×256 se convertirá al final en 8×8 (como se muestra en la Figura 1), mientras que una entrada con una forma de 416×416 dará como resultado un tensor de predicción de 13 × 13.

# Bloque de código 9 darknet53 = Darknet53() x = torch.randn(1, 3, 416, 416) out = darknet53(x) # Bloque de código 9 Salida original: torch.Size([1, 3, 416, 416]) después de convolucional0: torch.Size([1, 32, 416, 416]) después de convolucional1: torch.Size([1, 64, 208, 208]) después de residuales0 #0: antorcha.Tamaño([1, 64, 208, 208]) después de convolucional2: antorcha.Tamaño([1, 128, 104, 104]) después de residuales1 #0: antorcha.Tamaño([1, 128, 104, 104]) después de residuales1 #1: antorcha.Tamaño([1, 128, 104, 104]) después de convolucional3: antorcha.Tamaño([1, 256, 52, 52]) después de residuales2 #0: antorcha.Tamaño([1, 256, 52, 52]) después de residuales2 #1: antorcha.Tamaño([1, 256, 52, 52]) después de residuales2 #2: antorcha.Tamaño([1, 256, 52, 52]) después de residuales2 #3: antorcha.Tamaño([1, 256, 52, 52]) después de residuales2 #4: antorcha.Tamaño([1, 256, 52, 52]) después de residuales2 #5: antorcha.Tamaño([1, 256, 52, 52]) después de residuales2 #6: antorcha.Tamaño([1, 256, 52, 52]) después de residuales2 #7: antorcha.Tamaño([1, 256, 52, 52]) después de convolucional4: antorcha.Tamaño([1, 512, 26, 26]) después de residuales3 #0: antorcha.Tamaño([1, 512, 26, 26]) después de residuales3 #1: antorcha.Tamaño([1, 512, 26, 26]) después residuales3 #2 : antorcha.Tamaño([1, 512, 26, 26]) después de residuales3 #3 : antorcha.Tamaño([1, 512, 26, 26]) después de residuales3 #4 : antorcha.Tamaño([1, 512, 26, 26]) después de residuales3 #5 : antorcha.Tamaño([1, 512, 26, 26]) después de residuales3 #6 : torch.Size([1, 512, 26, 26]) después de los residuales3 #7: torch.Size([1, 512, 26, 26]) después de convolucional5: torch.Size([1, 1024, 13, 13]) después de los residuales4 #0: torch.Size([1, 1024, 13, 13]) después de los residuales4 #1: torch.Size([1, 1024, 13, 13]) después de los residuales4 #2: torch.Size([1, 1024, 13, 13]) después de los residuales4 #3: torch.Size([1, 1024, 13, 13])

En este punto también podemos ver cómo se ven los resultados producidos por las tres ramas simplemente imprimiendo las formas de rama0, rama1 y x como se muestra en el bloque de código 10 a continuación. Observe que las dimensiones espaciales de estos tres tensores varían. Más adelante, se aumentará el muestreo de los tensores de las capas más profundas para que podamos realizar una concatenación por canales con los de las capas menos profundas.

# Bloque de código 10 imprimir (salir[0].forma) # rama0 imprimir(salir[1].forma) # rama1 imprimir(salir[2].shape) # x # Bloque de código 10 Salida antorcha.Tamaño([1, 256, 52, 52]) antorcha.Tamaño([1, 512, 26, 26]) antorcha.Tamaño([1, 1024, 13, 13])

Implementación del cabezal de detección

Si regresa a la Figura 4, notará que cada uno de los cabezales de detección consta de dos capas de convolución. Sin embargo, estas dos circunvoluciones no son idénticas. En el bloque de código 11 a continuación utilizo el bloque convolucional para el primero y el nn.Conv2d simple para el segundo. Básicamente, esto se hace porque la segunda convolución actúa como la capa final y, por lo tanto, es responsable de proporcionar una salida sin procesar (en lugar de normalizarla y editarla con ReLU).

# Bloque de código 11 clase DetectorHead(nn.Module): def __init__(self, num_channels): super().__init__() self.convhead0 = Convolucional(in_channels=num_channels, out_channels=num_channels*2, kernel_size=3) self.convhead1 = nn.Conv2d(in_channels=num_channels*2, out_channels=NUM_PRIORS*(NUM_CLASS+5), kernel_size=1) def forward(self, x): print(f'originalt: {x.size()}') x = self.convhead0(x) print(f'after convhead0t: {x.size()}') x = self.convhead1(x) print(f'after convhead1t: {x.size()}') return x

Ahora, en Codeblock 12, intentaré simular el cabezal de detección de 13 × 13, por lo tanto, configuré el mapa de características de entrada para que tenga la forma de 512 × 13 × 13 (# (1)). Por cierto, sabrás de dónde viene el número 512 más adelante en la sección siguiente.

# Bloque de código 12 cabezal de detección = Cabezal de detección(num_channels=512) x = torch.randn(1, 512, 13, 13) #(1) out = cabezal de detección(x)

Y a continuación se muestra cómo se ve el resultado resultante. Podemos ver aquí que el tensor se expande a 1024 × 13 × 13 antes de reducirse finalmente a 255 × 13 × 13. Recuerde que en YOLOv3, siempre que establezcamos NUM_PRIORS en 3 y NUM_CLASS en 80, el número de canales de salida siempre será 255, independientemente del número de canales de entrada introducidos en el detector.

# Bloque de código 12 Salida del original: torch.Size([1, 512, 13, 13]) después de convhead0: torch.Size([1, 1024, 13, 13]) después de convhead1: torch.Size([1, 255, 13, 13])

Toda la arquitectura YOLOv3

Bien, ahora que hemos inicializado los componentes principales, lo que debemos hacer a continuación es construir toda la arquitectura YOLOv3. Aquí también discutiré los componentes restantes que no hemos cubierto. Sin embargo, el código es bastante largo, así que lo divido en dos bloques de código: el bloque de código 13a y el bloque de código 13b. Sólo asegúrese de que estos dos bloques de código estén escritos en la misma celda del cuaderno si desea ejecutarlo por su cuenta.

En el bloque de código 13a a continuación, lo primero que hacemos es inicializar el modelo principal (#(1)). A continuación, creamos una pila de 5 bloques convolucionales que alternativamente reduce a la mitad y duplica el número de canales. El bloque conv que reduce el número de canales usa un kernel 1×1 mientras que el que lo aumenta usa un kernel 3×3, al igual que la estructura que usamos en el bloque Residual. Inicializamos esta pila de 5 convoluciones para los tres cabezales de detección. Específicamente para los mapas de características que fluyen hacia las cabezas de 26×26 y 52×52, necesitamos inicializar otra capa de convolución (#(2) y #(4)) y una capa de muestreo superior (#(3) y #(5)) además de las 5 convoluciones.

# Bloque de código 13a clase YOLOv3(nn.Module): def __init__(self): super().__init__() ################################################ # Inicialización de la red troncal. self.darknet53 = Darknet53() #(1) ################################################# Para salida 13×13. self.conv0 = Convolucional(in_channels=1024, out_channels=512, kernel_size=1) self.conv1 = Convolucional(in_channels=512, out_channels=1024, kernel_size=3) self.conv2 = Convolucional(in_channels=1024, out_channels=512, kernel_size=1) self.conv3 = Convolucional(in_channels=512, out_channels=1024, kernel_size=3) self.conv4 = Convolucional(in_channels=1024, out_channels=512, kernel_size=1) self.detection_head_large_obj =DetectionHead(num_channels=512) ############################################### # Para salida de 26×26. self.conv5 = Convolucional(in_channels=512, out_channels=256, kernel_size=1) #(2) self.upsample0 = nn.Upsample(scale_factor=2) #(3) self.conv6 = Convolucional(in_channels=768, out_channels=256, kernel_size=1) self.conv7 = Convolucional(in_channels=256, out_channels=512, kernel_size=3) self.conv8 = Convolucional(in_channels=512, out_channels=256, kernel_size=1) self.conv9 = Convolucional(in_channels=256, out_channels=512, kernel_size=3) self.conv10 = Convolucional(in_channels=512, out_channels=256, kernel_size=1) self.detection_head_medium_obj = DetectorHead(num_channels=256) ################################################## Para salida de 52×52. self.conv11 = Convolucional(in_channels=256, out_channels=128, kernel_size=1) #(4) self.upsample1 = nn.Upsample(scale_factor=2) #(5) self.conv12 = Convolucional(in_channels=384, out_channels=128, kernel_size=1) self.conv13 = Convolucional(in_channels=128, out_channels=256, kernel_size=3) self.conv14 = Convolucional(in_channels=256, out_channels=128, kernel_size=1) self.conv15 = Convolucional(in_channels=128, out_channels=256, kernel_size=3) self.conv16 = Convolucional(in_channels=256, out_channels=128, kernel_size=1) self.detection_head_small_obj = DetecciónHead(num_channels=128)

Ahora en el bloque de código 13b definimos el flujo de la red dentro del método forward(). Aquí primero pasamos el tensor de entrada a través del modelo darknet53 (#(1)), que produce 3 tensores de salida: rama0, rama1 y x. Luego, lo que hacemos a continuación es conectar las capas una tras otra según el flujo que se muestra en la Figura 4.

# Bloque de código 13b def adelante(self, x): ############################################### # Backbone. rama0, rama1, x = self.darknet53(x) #(1) print(f'branch0ttt: {branch0.size()}') print(f'branch1ttt: {branch1.size()}') print(f'xttt: {x.size()}n') ############################################### # Flujo al cabezal de detección de 13×13. x = self.conv0(x) print(f'después de conv0tt: {x.size()}') x = self.conv1(x) print(f'después de conv1tt: {x.size()}') x = self.conv2(x) print(f'después de conv2tt: {x.size()}') x = self.conv3(x) print(f'después de conv3tt: {x.size()}') x = self.conv4(x) print(f'after conv4tt: {x.size()}') big_obj = self.detection_head_large_obj(x) print(f'detección de objetos grandest: {large_obj.size()}n') ############################################### # Flujo al cabezal de detección de 26×26. x = self.conv5(x) print(f'después de conv5tt: {x.size()}') x = self.upsample0(x) print(f'después de upsample0tt: {x.size()}') x = torch.cat([x, rama1], dim=1) print(f'después de concatenart: {x.size()}') x = self.conv6(x) print(f'después conv6tt: {x.size()}') x = self.conv7(x) print(f'después de conv7tt: {x.size()}') x = self.conv8(x) print(f'después de conv8tt: {x.size()}') x = self.conv9(x) print(f'después de conv9tt: {x.size()}') x = self.conv10(x) print(f'después de conv10tt: {x.size()}') medium_obj = self.detection_head_medium_obj(x) print(f'detección de objeto mediot: {medium_obj.size()}n') ############################################### # Flujo al cabezal de detección de 52×52. x = self.conv11(x) print(f'después de conv11tt: {x.size()}') x = self.upsample1(x) print(f'después de concatenatett: {x.size()}') x = torch.cat([x, branch0], dim=1) print(f'después de concatenatet: {x.size()}') x = self.conv12(x) print(f'después de conv12tt: {x.size()}') x = self.conv13(x) print(f'después de conv13tt: {x.size()}') x = self.conv14(x) print(f'después de conv14tt: {x.size()}') x = self.conv15(x) print(f'después de conv15tt: {x.size()}') x = self.conv16(x) print(f'después de conv16tt: {x.size()}') objeto_pequeño = self.detection_head_small_obj(x) print(f'detección de objetos pequeñost: {small_obj.size()}n') ############################################### # Tensores de predicción de retorno. devolver obj_grande, obj_medio, obj_pequeño

Como hemos completado el método forward(), ahora podemos probar todo el modelo YOLOv3 pasando una única imagen RGB de tamaño 416×416 como se muestra en el bloque de código 14.

# Bloque de código 14 yolov3 = YOLOv3() x = torch.randn(1, 3, 416, 416) out = yolov3(x)

A continuación se muestra cómo se ve el resultado después de ejecutar el bloque de código anterior. Aquí podemos ver que todo parece funcionar correctamente ya que la imagen ficticia pasó con éxito por todas las capas de la red. Una cosa que probablemente necesite saber es que el mapa de características de 768 canales en la línea n.° (4) se obtiene de la concatenación entre el tensor en las líneas n.° (2) y n.° (3). Lo mismo también se aplica al tensor de 384 canales en la línea n.° (6), en el que es la concatenación entre los mapas de características en las líneas n.° (1) y n.° (5).

# Bloque de código 14 Rama de salida0: antorcha.Tamaño([1, 256, 52, 52]) #(1) rama1: antorcha.Tamaño([1, 512, 26, 26]) #(2) x: antorcha.Tamaño([1, 1024, 13, 13]) después de conv0: antorcha.Tamaño([1, 512, 13, 13]) después de conv1 : antorcha.Tamaño([1, 1024, 13, 13]) después de conv2 : antorcha.Tamaño([1, 512, 13, 13]) después de conv3 : antorcha.Tamaño([1, 1024, 13, 13]) después de conv4 : antorcha.Tamaño([1, 512, 13, 13]) detección de objetos grandes : antorcha.Tamaño([1, 255, 13, 13]) después de conv5: torch.Size([1, 256, 13, 13]) después de upsample0: torch.Size([1, 256, 26, 26]) #(3) después de concatenar: torch.Size([1, 768, 26, 26]) #(4) después de conv6: torch.Size([1, 256, 26, 26]) después de conv7: antorcha.Tamaño([1, 512, 26, 26]) después de conv8: antorcha.Tamaño([1, 256, 26, 26]) después de conv9: antorcha.Tamaño([1, 512, 26, 26]) después de conv10: antorcha.Tamaño([1, 256, 26, 26]) detección de objetos medianos: torch.Size([1, 255, 26, 26]) después de conv11: torch.Size([1, 128, 26, 26]) después de upsample1: torch.Size([1, 128, 52, 52]) #(5) después de concatenar: torch.Size([1, 384, 52, 52]) #(6) después de conv12: torch.Size([1, 128, 52, 52]) después de conv13: torch.Size([1, 256, 52, 52]) after conv14: torch.Size([1, 128, 52, 52]) after conv15: torch.Size([1, 256, 52, 52]) after conv16: torch.Size([1, 128, 52, 52]) detección de objetos pequeños: antorcha.Tamaño([1, 255, 52, 52])

Y solo para aclarar las cosas aquí, también imprimo el resultado de cada cabezal de detección en el bloque de código 15 a continuación. Podemos ver aquí que todos los tensores de predicción resultantes tienen la forma que esperábamos antes. Por lo tanto, creo que nuestra implementación de YOLOv3 es correcta y, por lo tanto, está lista para entrenar.

# Bloque de código 15 imprimir (salir[0].forma) imprimir (salir[1].forma) imprimir (salir[2].shape) # Bloque de código 15 Salida antorcha.Tamaño([1, 255, 13, 13]) antorcha.Tamaño([1, 255, 26, 26]) antorcha.Tamaño([1, 255, 52, 52])

Creo que eso es prácticamente todo sobre YOLOv3 y su implementación de arquitectura desde cero. Como hemos visto anteriormente, los autores lograron una mejora impresionante en el rendimiento en comparación con la versión anterior de YOLO, a pesar de que los cambios que hicieron en el sistema no fueron lo que consideraron significativos, de ahí el título "Una mejora incremental".

Avíseme si detecta algún error en este artículo. También puedes encontrar el código completamente funcional en mi repositorio de GitHub.[13]. Gracias por leer, ¡nos vemos en mi próximo artículo!

Referencias

[1]Joseph Redmon y Ali Farhadi. YOLOv3: una mejora incremental. Arxiv. https://arxiv.org/abs/1804.02767 [Consultado el 24 de agosto de 2025].

[2]Muhamad Ardi. Tutorial en papel de YOLOv1: El día en que YOLO vio el mundo por primera vez. Medio. https://ai.gopubby.com/yolov1-paper-walkthrough-the-day-yolo-first-saw-the-world-ccff8b60d84b [Consultado el 1 de marzo de 2026].

[3]Muhamad Ardi. Tutorial de la función de pérdida YOLOv1: regresión para todos. Medio. https://ai.gopubby.com/yolov1-loss-function-walkthrough-regression-for-all-18c34be6d7cb [Consultado el 1 de marzo de 2026].

[4]Muhamad Ardi. Tutorial en papel de YOLOv2 y YOLO9000: mejor, más rápido y más potente. Hacia la ciencia de datos. https://towardsdatascience.com/yolov2-yolo9000-paper-walkthrough-better-faster-stronger/ [Consultado el 1 de marzo de 2026].

[5]Imagen creada originalmente por el autor.

[6]Introducción de YOLO v3 a la detección de objetos con TensorFlow 2. PyLessons. https://pylessons.com/YOLOv3-TF2-introduction [Consultado el 24 de agosto de 2025].

[7]aladdinpersson. YOLOv3. GitHub. https://github.com/aladdinpersson/Machine-Learning-Collection/blob/master/ML/Pytorch/object_detection/YOLOv3/model.py [Consultado el 24 de agosto de 2025].

[8]Kaiming He et al. Aprendizaje residual profundo para el reconocimiento de imágenes. Arxiv. https://arxiv.org/abs/1512.03385 [Consultado el 24 de agosto de 2025].

[9]Langcai Cao et al. Un algoritmo de detección de texto para imágenes de ejercicios de estudiantes basado en CTPN y YOLOv3 mejorado. Acceso IEEE. https://ieeexplore.ieee.org/document/9200481 [Consultado el 24 de agosto de 2025].

[10]Imagen del autor, parcialmente generada por Gemini.

[11]Joseph Redmon et al. Solo miras una vez: detección de objetos unificada en tiempo real. Arxiv. https://arxiv.org/pdf/1506.02640 [Consultado el 5 de julio de 2025].

[12]ML para nerds. YOLO-V3: Una mejora incremental || SERIE DE DETECCIÓN DE OBJETOS YOLO. YouTube. https://www.youtube.com/watch?v=9fhAbvPWzKs&t=174s [Consultado el 24 de agosto de 2025].

[13]MuhammadArdiPutra. Aún mejor, pero no tanto: YOLOv3. GitHub. https://github.com/MuhammadArdiPutra/medium_articles/blob/main/Even%20Better%2C%20but%20Not%20That%20Much%20-%20YOLOv3.ipynb [Consultado el 24 de agosto de 2025].