Dans le cadre de l'analyse automatique de structures complexes comme les échafaudages, les algorithmes géométriques traditionnels pour l'extraction d'éléments verticaux et horizontaux s'avèrent souvent inefficaces et chronophages. L'approche par apprentissage profond, et plus particulièrement par réseaux de neurones sur nuages de points, offre une alternative robuste. Ce guide détaille la mise en œuvre, l'entraînement et l'évaluation d'un réseau PointNet en utilisant le framework PaddlePaddle pour effectuer une segmentation sémantique sur des données 3D.
Configuration et prérequis
L'architecture PointNet nécessitant une entrée de dimension fixe, le pipeline de traitement inclut une phase critique de sous-échantillonnage aléatoire. Les étapes principales consistent à charger les données brutes, normaliser les coordonnées, diviser le jeu de données et instancier le modèle.
import os
import numpy as np
import matplotlib.pyplot as plt
import paddle
import paddle.nn as nn
import paddle.nn.functional as F
from paddle.io import Dataset, DataLoader
from paddle.nn import Conv2D, BatchNorm, ReLU, MaxPool2D, Linear, Softmax, Sequential
import warnings
# Suppression des avertissements non critiques
warnings.filterwarnings("ignore")
# Configuration des chemins de données
PATH_DATA = './dataset/raw_data'
PATH_LABELS = './dataset/labels'
# Nombre de points fixes par nuage (PointNet nécessite une taille fixe)
NUM_POINTS = 1024
Visualisation et chargement des données
La visualisation 3D est essentielle pour vérifier l'intégrité des annotations. Le script suivant définit une fonction pour afficher les nuages de points colorés selon leurs étiquettes, puis procède au chargement et au prétraitement des fichiers.
def plot_cloud_3d(coords, labels, caption="Visualisation"):
"""Affiche un nuage de points 3D avec des couleurs par classe."""
fig = plt.figure(figsize=(10, 8))
ax = fig.add_subplot(111, projection='3d')
# Palette de couleurs
colors = ['#0000FF', '#FFFF00', '#FF0000', '#008000']
class_names = ['Fond', 'Support', 'Autre']
for idx, name in enumerate(class_names):
mask = (labels == idx)
if np.any(mask):
ax.scatter(coords[mask, 0], coords[mask, 1], coords[mask, 2],
c=colors[idx % len(colors)], label=name, alpha=0.6, s=5)
ax.set_title(caption)
ax.legend()
plt.show()
# Préparation des listes de stockage
input_clouds = []
target_labels = []
files = [f for f in os.listdir(PATH_DATA) if f.endswith('.pts')]
for filename in files:
# Construction des chemins
label_file = filename.replace('.pts', '.seg')
pts_path = os.path.join(PATH_DATA, filename)
seg_path = os.path.join(PATH_LABELS, label_file)
# Chargement des fichiers texte
raw_xyz = np.loadtxt(pts_path)
raw_seg = np.loadtxt(seg_path).astype(int)
# Filtrage des nuages trop petits
if len(raw_xyz) < NUM_POINTS:
continue
# Sous-échantillonnage aléatoire
select_idx = np.random.choice(len(raw_xyz), NUM_POINTS, replace=False)
sampled_xyz = raw_xyz[select_idx]
sampled_seg = raw_seg[select_idx]
# Centrage et normalisation
centroid = np.mean(sampled_xyz, axis=0)
normalized_xyz = sampled_xyz - centroid
max_dist = np.max(np.linalg.norm(normalized_xyz, axis=1))
normalized_xyz /= max_dist
input_clouds.append(normalized_xyz)
target_labels.append(sampled_seg)
Dataset et DataLoader personnalisés
Nous créons une classe héritant de paddle.io.Dataset pour encapsuler la logique d'accès aux données. Le jeu de données est ensuite divisé en ensembles d'entraînement et de validation.
class PointCloudDataset(Dataset):
def __init__(self, x_data, y_data):
self.x_data = x_data
self.y_data = y_data
def __getitem__(self, idx):
# PaddlePaddle attend une forme (C, N, 3) pour la convolution
cloud = self.x_data[idx].reshape(1, NUM_POINTS, 3).astype('float32')
label = self.y_data[idx].astype('int64')
return cloud, label
def __len__(self):
return len(self.x_data)
# Division des données (80% entraînement, 20% validation)
split_ratio = 0.8
split_idx = int(len(input_clouds) * split_ratio)
train_set = PointCloudDataset(input_clouds[:split_idx], target_labels[:split_idx])
val_set = PointCloudDataset(input_clouds[split_idx:], target_labels[split_idx:])
print(f"Échantillons d'entraînement : {len(train_set)}")
print(f"Échantillons de validation : {len(val_set)}")
# Création des loaders
BATCH_SIZE = 32
train_loader = DataLoader(train_set, batch_size=BATCH_SIZE, shuffle=True)
val_loader = DataLoader(val_set, batch_size=BATCH_SIZE, shuffle=False)
Architecture du modèle PointNet
Le réseau PointNet implémenté ici utilise deux réseaux de transformation (T-Net) pour aligner les données en entrée et les caractéristiques, suivis de perceptrons multicouches (MLP) pour l'extraction de caractéristiques globales et locales.
class PointNetSegmentor(nn.Layer):
def __init__(self, num_classes=4, num_points=1024):
super(PointNetSegmentor, self).__init__()
self.num_points = num_points
# T-Net pour les coordonnées d'entrée
self.tnet_input = Sequential(
Conv2D(1, 64, (1, 3)),
BatchNorm(64), ReLU(),
Conv2D(64, 128, (1, 1)),
BatchNorm(128), ReLU(),
Conv2D(128, 1024, (1, 1)),
BatchNorm(1024), ReLU(),
MaxPool2D((num_points, 1))
)
self.fc_input = Sequential(
Linear(1024, 512), ReLU(),
Linear(512, 256), ReLU(),
Linear(256, 9)
)
# MLP pour l'extraction de caractéristiques initiales
self.mlp_1 = Sequential(
Conv2D(1, 64, (1, 3)),
BatchNorm(64), ReLU(),
Conv2D(64, 64, (1, 1)),
BatchNorm(64), ReLU()
)
# T-Net pour les caractéristiques
self.tnet_feat = Sequential(
Conv2D(64, 64, (1, 1)),
BatchNorm(64), ReLU(),
Conv2D(64, 128, (1, 1)),
BatchNorm(128), ReLU(),
Conv2D(128, 1024, (1, 1)),
BatchNorm(1024), ReLU(),
MaxPool2D((num_points, 1))
)
self.fc_feat = Sequential(
Linear(1024, 512), ReLU(),
Linear(512, 256), ReLU(),
Linear(256, 64 * 64)
)
# MLP pour les caractéristiques globales
self.mlp_2 = Sequential(
Conv2D(64, 64, (1, 1)),
BatchNorm(64), ReLU(),
Conv2D(64, 128, (1, 1)),
BatchNorm(128), ReLU(),
Conv2D(128, 1024, (1, 1)),
BatchNorm(1024), ReLU()
)
# Tête de segmentation combinant caractéristiques locales et globales
self.seg_head = Sequential(
Conv2D(1088, 512, (1, 1)),
BatchNorm(512), ReLU(),
Conv2D(512, 256, (1, 1)),
BatchNorm(256), ReLU(),
Conv2D(256, 128, (1, 1)),
BatchNorm(128), ReLU(),
Conv2D(128, num_classes, (1, 1)),
Softmax(axis=1)
)
def forward(self, x):
batch_size = x.shape[0]
# Transformation d'entrée
trans_input = self.tnet_input(x)
trans_input = paddle.squeeze(trans_input)
trans_input = self.fc_input(trans_input)
trans_input = paddle.reshape(trans_input, [batch_size, 3, 3])
# Appliquer la transformation aux coordonnées
x_r = paddle.reshape(x, [batch_size, self.num_points, 3])
x_r = paddle.matmul(x_r, trans_input)
x_r = paddle.unsqueeze(x_r, axis=1)
# Premier MLP
feat = self.mlp_1(x_r)
# Transformation de caractéristiques
trans_feat = self.tnet_feat(feat)
trans_feat = paddle.squeeze(trans_feat)
trans_feat = self.fc_feat(trans_feat)
trans_feat = paddle.reshape(trans_feat, [batch_size, 64, 64])
# Appliquer la transformation aux caractéristiques
feat_r = paddle.reshape(feat, [batch_size, 64, self.num_points])
feat_r = paddle.transpose(feat_r, [0, 2, 1])
feat_r = paddle.matmul(feat_r, trans_feat)
feat_r = paddle.transpose(feat_r, [0, 2, 1])
feat_r = paddle.unsqueeze(feat_r, axis=-1)
point_feat = feat_r # Sauvegarder pour la concaténation
# Extraction de caractéristique globale
global_feat = self.mlp_2(feat_r)
global_feat = paddle.max(global_feat, axis=2)
# Duplication et concaténation
global_feat_exp = paddle.tile(paddle.unsqueeze(global_feat, axis=1),
[1, self.num_points, 1, 1])
concat_feat = paddle.concat([point_feat, global_feat_exp], axis=1)
# Segmentation finale
out = self.seg_head(concat_feat)
out = paddle.squeeze(out, axis=-1)
out = paddle.transpose(out, [0, 2, 1])
return out
# Instanciation du modèle
model = PointNetSegmentor(num_classes=4)
criterion = nn.CrossEntropyLoss()
optimizer = paddle.optimizer.Adam(parameters=model.parameters(), learning_rate=0.001)
Boucle d'entraînement et évaluation
Le processus d'entraînement inclut la sauvegarde périodique des poids et l'évaluation sur le jeu de validation pour conserver le meilleur modèle.
EPOCHS = 30
CHECKPOINT_DIR = './checkpoints'
os.makedirs(CHECKPOINT_DIR, exist_ok=True)
best_accuracy = 0.0
for epoch in range(EPOCHS):
model.train()
epoch_loss = 0
metric = paddle.metric.Accuracy()
for batch_id, (data, label) in enumerate(train_loader):
# Forward pass
preds = model(data)
loss = criterion(preds, label)
# Backward pass
loss.backward()
optimizer.step()
optimizer.clear_grad()
# Statistiques
epoch_loss += loss.numpy()[0]
# Calcul de la précision (aplatir les tenseurs)
preds_flat = paddle.reshape(preds, [-1, preds.shape[-1]])
labels_flat = paddle.reshape(label, [-1, 1])
correct = metric.compute(preds_flat, labels_flat)
metric.update(correct)
avg_loss = epoch_loss / (batch_id + 1)
train_acc = metric.accumulate()
print(f"Epoch {epoch+1}/{EPOCHS} | Loss: {avg_loss:.4f} | Acc: {train_acc:.4f}")
# Validation et sauvegarde
if (epoch + 1) % 2 == 0:
model.eval()
val_metric = paddle.metric.Accuracy()
with paddle.no_grad():
for val_data, val_label in val_loader:
val_preds = model(val_data)
v_preds_flat = paddle.reshape(val_preds, [-1, val_preds.shape[-1]])
v_labels_flat = paddle.reshape(val_label, [-1, 1])
v_correct = val_metric.compute(v_preds_flat, v_labels_flat)
val_metric.update(v_correct)
current_val_acc = val_metric.accumulate()
print(f"--> Validation Accuracy: {current_val_acc:.4f}")
if current_val_acc > best_accuracy:
best_accuracy = current_val_acc
paddle.save(model.state_dict(), os.path.join(CHECKPOINT_DIR, 'best_model.pdparams'))
print("Nouveau meilleur modèle sauvegardé.")
model.train()
Inférence et test sur un échantillon
Une fois le modèle entraîné, nous chargeons les meilleurs poids pour effectuer une prédiction sur un nuage de points test et visualiser le résultat comparé à la vérité terrain.
# Chargement du modèle
inference_model = PointNetSegmentor(num_classes=4)
state_dict = paddle.load(os.path.join(CHECKPOINT_DIR, 'best_model.pdparams'))
inference_model.set_state_dict(state_dict)
inference_model.eval()
# Sélection d'un exemple de test
test_cloud = input_clouds[0]
test_label = target_labels[0]
# Préparation pour le modèle (batch dimension)
input_tensor = paddle.to_tensor(test_cloud.reshape(1, 1, NUM_POINTS, 3), dtype='float32')
# Prédiction
prediction = inference_model(input_tensor)
pred_labels = paddle.argmax(prediction, axis=-1).numpy()[0]
# Affichage des résultats
plot_cloud_3d(test_cloud, test_label, caption="Vérité Terrain")
plot_cloud_3d(test_cloud, pred_labels, caption="Prédiction Réseau")
Les résultats montrent que le réseau est capable de distinguer les structures, bien que la performance puisse fluctuer en fonction de la quantité de données d'entraînement disponibles. L'ajout de données supplémentaires et l'augmentation de données (data augmentation) permettraient d'améliorer la robustesse de la segmentation.