3 minute read

Esta semana me he dedicado a ajustar el entrenamiento de PointNet++ para mejorar el resultado de la segmentación en la medida de lo posible. Para ello he ajustado varios hiperparámetros referentes a la propia arquitectura y proceso de entrenamiento. También he probado el modelo con nubes de puntos del dataset Rellis3D. El modelo tiene problemas con Rellis ya que la característica remission que se utilizó para entrenar con GOOSE dataset varía significativamente en media y varianza con Rellis3D. Aun no he podido investigar la causa de esto a fondo, pero tendrá que ver con las características de los sensores utilizados o las configuraciones de estos.

Entrenamiento PointNet++

Para afinar los resultados del entrenamiento de PointNet++ he modificado el tamaño de las Query Balls (Ventanas 3D de tres tamaños que componen la estructura jerárquica) adaptándolas al entorno espacial realista de GOOSE dataset o cualquier nube de puntos con tamaños absolutos. He elegido los tamaños de 1, 2.5 y 5 metros de radio. La estructura jerárquica consigue recoger características de relaciones espaciales mucho más complejas.

También he añadido un learning rate dinámico para ajustarlo a cada época y así ayudar a converger de manera más estable.

Composición del de PointNet++ ajustado

class PointNet2SemSeg(nn.Module):
    def __init__(self, num_classes=9, normal_channel=False):
        super(PointNet2SemSeg, self).__init__()
        in_channel = 6 if normal_channel else 3

        # Set Abstraction layers (encoder)
        self.sa1 = PointNetSetAbstraction(npoint=1024, radius=1, nsample=32, in_channel=in_channel, mlp=[32, 32, 64], group_all=False)
        self.sa2 = PointNetSetAbstraction(npoint=256, radius=2.5, nsample=64, in_channel=64 + 3, mlp=[64, 64, 128], group_all=False)
        self.sa3 = PointNetSetAbstraction(npoint=64, radius=5, nsample=256, in_channel=128 + 3, mlp=[128, 128, 256], group_all=False)

        # Feature Propagation layers (decoder)
        self.fp3 = PointNetFeaturePropagation(in_channel=256 + 128, mlp=[256, 256])
        self.fp2 = PointNetFeaturePropagation(in_channel=256 + 64, mlp=[256, 128])
        self.fp1 = PointNetFeaturePropagation(in_channel=128, mlp=[128, 128, 128])

        # MLP profundo para segmentación con remission (entrada: 128+1=129 canales)
        self.conv1 = nn.Conv1d(129, 128, 1)
        self.bn1 = nn.BatchNorm1d(128)
        self.drop1 = nn.Dropout(0.5)

        self.conv2 = nn.Conv1d(128, 64, 1)
        self.bn2 = nn.BatchNorm1d(64)
        self.drop2 = nn.Dropout(0.3)

        self.conv3 = nn.Conv1d(64, 32, 1)
        self.bn3 = nn.BatchNorm1d(32)
        self.drop3 = nn.Dropout(0.3)

        self.conv4 = nn.Conv1d(32, num_classes, 1)

    def forward(self, x, remission):
        """
        x: (B, 3, N)         -> Coordenadas
        remission: (B, 1, N) -> Remission 
        """
        B, _, N = x.shape
        l0_xyz = x
        l0_points = None  # No usamos características adicionales al inicio

        # Encoder
        l1_xyz, l1_points = self.sa1(l0_xyz, l0_points)
        l2_xyz, l2_points = self.sa2(l1_xyz, l1_points)
        l3_xyz, l3_points = self.sa3(l2_xyz, l2_points)

        # Decoder
        l2_points = self.fp3(l2_xyz, l3_xyz, l2_points, l3_points)
        l1_points = self.fp2(l1_xyz, l2_xyz, l1_points, l2_points)
        l0_points = self.fp1(l0_xyz, l1_xyz, l0_points, l1_points)

        # Concatenar remission antes del MLP final
        l0_points = torch.cat([l0_points, remission], dim=1) 

        # MLP profundo para clasificación por punto
        x = F.relu(self.bn1(self.conv1(l0_points)))
        x = self.drop1(x)
        x = F.relu(self.bn2(self.conv2(x)))
        x = self.drop2(x)
        x = F.relu(self.bn3(self.conv3(x)))
        x = self.drop3(x)
        x = self.conv4(x)
        x = F.log_softmax(x, dim=1)

        return x

Configuración del Optimizador Adam

import torch.nn as nn
import torch.optim as optim
from torch.optim.lr_scheduler import ReduceLROnPlateau

# Pesos para la función de pérdida
# [1.36, 11.63, 14.13, 18.82, 1.09, 0.49, 0.20, 6.70, 20.54] ideales

alpha = torch.tensor([0.8, 1, 2, 1, 0.85, 0.5, 0.3, 1, 1], dtype=torch.float32).to('cuda')
alpha = alpha / alpha.sum()

criterion = nn.NLLLoss(weight=alpha)
# criterion = PointNetSegLoss(alpha=alpha, gamma=2, size_average=True, dice=False) Focal Loss (no funcional)

optimizer = optim.Adam(model.parameters(), lr=0.01)
scheduler = ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=5, verbose=True, min_lr=1e-6)

Resultados del Entrenamiento

Los resultados mejoraron tanto en métricas como en interpretación visual. En 8 épocas de entrenamiento se obtuvo un 83 % de Accuracy y un 42.8 % de mIoU.

Matriz de confusión

Visor 3D

Prueba del modelo con Rellis3D

Desarrollé un pipeline para realizar la inferencia de nubes de puntos del dataset Rellis3D utilizando el modelo y visualizar los resultados. No hice agrupaciones de etiquetas para evaluar numericamente la precisión, únicamente me dio tiempo a viualizar los resultados con las 9 clases con las que está entrenado el modelo. Los resultados fueron malos porque la característica remission no es de la misma naturaleza. Se investigará la naturaleza de los datos de Rellis3D para encontrar la causa exacta de este problema. Seguramente las características adicionales a la geometría (x,y,z) varían su contenido dependiendo de las configuraciones de los sensores o sus propias características.

Se puede apreciar como la caracteristica remission confunde al modelo y en los puntos más cercanos tiende a segmentar como ‘Sign’ (señal, con superficie reflectante).

Nube con mapa de color (Remission)

Visor 3D

Nube de Etiquetas Predichas

Visor 3D