Dans ce post, j’implémente un système de segmentation de depth map en temps réel, basé sur l’analyse d’une normal map.
L’analyse d’une depth map (obtenue par une caméra RGB-D par exemple) n’est pas une tâche particulièrement facile ou rapide. Le but de ce post est d’implémenter une méthode pour en segmenter les éléments, comme les objets, plans, n’importe quelle surface 3D en fait.
Comme toujours, j’ai une certaine aversion (méfiance ?) envers les réseaux de neurone donc je fais en sorte d’en utiliser le moins possible, et toujours dans un contexte où leur bon fonctionnement est garanti. L’état de l’art de la segmentation de depth map en temps réel ([4]) utilise à la fois une méthode de segmentation par normales, comme [1], [2] et [3], en y ajoutant un réseau de neurone pour une proposition de classes. Comme je n’ai absolument pas besoin de classes ici, je me contenterais d’une séparation par normales.
Pour rappel sur les depth map:
- Une depth map est une image dont chaque pixel équivaut à une mesure de distance
- Les trous (valeurs a 0) dans les depths maps représentent une absence de données.
Réparer la depth map
Afin de corriger quelques défauts de la depth map en prévision de son utilisation pour calculer les normales, je lui ai appliqué quelques traitements.
J’ai ici utilisé une fermeture morphologique, un filtrage médian puis un filtrage bilatéral, qui ont pour but dans l’ordre, de refermer les petits trous introduits par la warping, lisser ces modifications et effectuer un lissage global favorisant la conservation des surfaces planes.
cv::Mat newTempoMat
cv::morphologyEx(depthMap, newTempoMat, cv::MORPH_CLOSE, kernel); // kernel is a 3x3 square
cv::medianBlur(newTempoMat, newTempoMat, 3);
cv::bilateralFilter(newTempoMat, depthMap, 7, 31, 15);
Les papiers que j’ai utilisés comme référence utilisent tous une vertex map, qui est une depth map convertie en unités “réelles”.
Calculer la Normal Map
Une normal map est une image RGB représentant les vecteurs normaux a chaque plan au niveau de chaque pixel de l’image de départ (voir Wikipedia: Normale a une surface pour plus de détails, je n’irais pas plus loin ici).
Cette structure est composée de NxM vecteurs 3D pour une image de taille NxM, et est intelligemment représentée par une image RGB dont chaque channel stocke une composante du vecteur. L’effet final est une image en nuances de bleu/vert/rouge en fonction de la direction dominante de chaque normale (voir Blog de Fabrice Bouyé pour de belles images).
Une normal map construite à partir de données théoriques est plutôt “jolie”. Elle présente peu de bruit (donc visuellement un gradient de couleurs continu), mais dans nos mesures réelles, on va relever beaucoup de bruit.
On peut calculer la valeur d’une normale a un point (x, y)
de la depth map avec le code c++ suivant :
float dzdx = ( depthMap.at<float>(x + 1, y) - depthMap.at<float>(x - 1, y) ) / 2.0;
float dzdy = ( depthMap.at<float>(x, y + 1) - depthMap.at<float>(x, y + 1) ) / 2.0;
normalMap.at<Vec3f>(x, y) = cv::normalize( cv::Vec3f(-dzdx, -dzdy, -1.0));
On ignore bien sur les points sans donnée de profondeur. Dans l’ordre : image RGB, depth map, normal map.
Sur la normal map, on peut voir des artefacts (lignes verticales de couleur cyan), probablement dues au traitement effectué sur la depth map précédemment. On remarque surtout que même les surfaces planes (comme le sol et la porte du rangement) présentent un bruit important (sans parler des éléments en arrière-plan, complètement méconnaissables). Ces erreurs de calcul dans les normals sont dues aux erreurs de mesure dans la depth map (voir [6] pour une modélisation de ces erreurs).
J’ai également ajouté un filtrage median 3 x 3 sur la normal map finale, afin de diminuer le bruit.
Détection de surfaces concaves
On va ensuite parcourir la normal map pour y détecter les surfaces concaves.
J’ai utilisé un opérateur adapté de [1], qui compare la convexité d’un point (x, y)
avec ses points voisin (xn, yn)
:
const cv::Vec3f& centerNormal = normalMap.at<cv::Vec3f>(x, y); //normal au centre de la zone à analyser
const float& centerDepth = depthMap.at<float>(x, y); //Valeur de la depth map au centre
float minConcavity = 1;
for( each neigbor ) {
float neightborDepth = depthMap.at<float>(xn, yn);
double vertexDot = centerVertex.dot(cv::Vec3f(xn - x, yn - y, neightborDepth - centerDepth));
if ( vertexDot <= 0 ) {
minConcavity = std::min(minConcavity, centerNormal.dot( normalMap.at<cv::Vec3f>(xn, yn) );
}
}
thresholdedConcavity = minConcavity > 0.94;
J’ai aussi ajouté une méthode permettant de réduire grandement le bruit des mesures, observée dans [2] et [5], non présentée dans le code ici.
Le résultat de la map de concavité est montré ici :
Cette image est seuillée avec une valeur de 0.94 (comme dans [1]) afin de récupérer les bords.
Détection de discontinuités de surfaces
Cette fois-ci, on cherche les discontinuités de profondeur.
J’ai utilisé le même opérateur que [1], qui compare la distance d’un point (x, y)
avec un point voisin (xn, yn)
:
const float& centerDepth = depthMap.at<float>(x, y); //Valeur de la depth map au centre
float maxDiscontinuity = 0;
for( each neigbor ) {
float neightborDepth = depthMap.at<float>(xn, yn);
double vertexDot = centerVertex.dot(cv::Vec3f(xn - x, yn - y, neightborDepth - centerDepth));
maxDiscontinuity = std::max( maxDiscontinuity, abs(vertexDot) );
}
thresholdedDiscontinuity = maxDiscontinuity < (0.12 + 0.19 * pow(centerDepth - 40, 2.0)); //noise model from [6]
La dernière ligne montre le seuillage de la valeur de discontinuité par le modèle du bruit d’une caméra RGB-D (ici, une Kinect).
On va alors combiner les informations de concavité et discontinuité pour obtenir la map de contours.
Map de contours
Les maps de concavité et de discontinuité sont fusionnées à l’aide d’un opérateur AND binaire &
afin de récupérer une map de contours :
Cette map de contours peut être lissé avec une fermeture géodésique suivie d’une ouverture morphologique pour filtrer les plus petits éléments.
Segmentation de composants
Il suffit maintenant d’appliquer une simple détection de composants connectés pour récupérer une première depth map segmentée. J’ai également filtré les composants avec une aire sous un certain seuil afin de finir le nettoyage des données.
Pour optimiser le résultat final, j’ai implémenté tout ce système avec un paramètre permettant de régler l’échelle de traitement. Effectuer le traitement sur la depth map totale (640x480 pixels) serait trop lourd, et elle est donc réduite par un facteur d’échelle (0.45 pour un rapport de performance/précision optimale selon mes tests).
Voilà le résultat de segmentation final à côté de l’image RGB originelle.
Comparaison qualitative
A titre d’indication, voici une comparaison de la segmentation obtenue avec CAPE, un système capable de détecter uniquement les plans et cylindres.
A gauche, segmentation avec CAPE, à droite la méthode de segmentation présentée ici (scale=1).
Ici, CAPE donne de meilleurs résultats, car la scène est composée majoritairement de plans et cylindres, mais on peut s’attendre à ce que la segmentation présentée dans ce blog soit plus apte à gérer des objets de forme quelconques. Cette méthode de segmentation permet ici de récupérer des surfaces non segmentées par CAPE, notamment dans le background. Elle demande également plus de ressources que CAPE, mais est facilement parallélisable.
En somme, ces deux méthodes peuvent être fusionnées pour tirer parti des avantages de chacune.
Pour de meilleures reconstructions de masques, il manque une gestion d’une carte en mémoire, ce qui transforme le système en SLAM comme dans [1].
Bibliographie:
- [1] Real-Time and Scalable Incremental Segmentation on Dense SLAM
- [2] 3D scene segmentation for autonomous robot grasping
- [3] KinectFusion: Real-time 3D reconstruction and interaction using a moving depth camera
- [4] Fast and Accurate Semantic Mapping through Geometric-based Incremental Segmentation
- [5] Generative Cognitive Representation for Embodied Agents
- [6] Modeling Kinect Sensor Noise for Improved 3D Reconstruction and Tracking