Calibration de caméra et reconstruction 3D

De Wiki du LAMA (UMR 5127)
Aller à la navigation Aller à la recherche

Noah CUNEO

Tuteur : Stéphane BREUILS

GitHub repository : https://github.com/NoahGNC/calibration"

Introduction

Nous allons dans ce compte rendu, voir et comprendre ce qu’est la vision par ordinateur.

Plus particulièrement comment à partir d’une caméra nous pouvons traiter l’information d’une image.

Dans le but d’obtenir divers résultats tel que la création d’un panorama ou bien la reconstruction 3D d’un objet à partir de photos.

Pour cela, nous allons nous aider de la bibliothèque OpenCV.

Qui est une bibliothèque conçue spécifiquement pour la vision par ordinateur et le traitement d’images.

*Notons que par soucis de lisibilité, nous allons uniquement afficher des morceaux de code. Mais que nous ferons une redirection systématique vers le code correspondant sur GitHub

Présentation d'OpenCV

OpenCV est donc, comme dit dans l’introduction, une librairie Python axée sur la vision par ordinateur.
En effet, elle propose une grande variété de fonctions utiles au traitement d’images. Comme par exemple :

sift = cv.SIFT_create()
kp = sift.detect(gray,None)

Qui permet de détecter des points clés sur une image.

Ou également par exemple :

M, mask = cv.findHomography(src_pts, dst_pts, cv.RANSAC,5.0)

Qui permet d’estimer une matrice d’homographie.

Mais nous verrons l’utilité de ces fonctions plus en détails ultérieurement.

OpenCV permet également, à l’instar de Tkinter ou Pygame, de générer une interface graphique.

Elle est cependant très limitée puisqu’elle à été créée seulement à l’intention d’afficher des images.

Voici un exemple de comment on affiche une image sur OpenCV :

image = cv2.imread('image.jpg', cv2.IMREAD_GRAYSCALE)
cv2.imshow('Image', image)
cv2.waitKey(0)
cv2.destroyAllWindows()

Ce code va par exemple, afficher l’image « image.jpg » en nuance de gris de cette forme :


Photo d'immeuble en noir et blanc


Nous allons réutiliser cette image ultérieurement, afin que les modifications que nous allons étudier soient mieux compréhensibles.

Présentation de mon code

Lors de ma lecture de la documentation d'OpenCV. J'ai remarqué que certaines tâches pouvaient se répéter.

J'ai donc dans l'optique de rendre mon code plus compréhensible tout en évitant de répéter les tâches déjà effectuées, créé quelque classe permettant d'améliorer ces caractéristiques.

Présentation de mes classes

Voici la classe CameraParametre.

Je l'ai codé afin de stocker dans un fichier dictionnaire les paramètre intrinsèque de la caméra, ainsi que les paramètres extrinsèques pour les images données.

Le nom de la caméra sert ensuite à créer un fichier JSON avec la classe "Sauvegarde", ce qui permet ultérieurement d'obtenir ces paramètres sans avoir à les recalculer.

class CameraParametre() :
    """Permet de stocker les paramètres de la caméra"""
    def __init__(self, nom, ret, mtx, dist, rvecs, tvecs, proj) :
        self.nom = nom
        self.ret = ret
        self.mtx = mtx
        self.dist = dist
        self.rvecs = rvecs
        self.tvecs = tvecs
        self.proj = proj
    
    def save_camera(self, chemin:str) :
        """sauvegarde les parametres de la camra dans le chemin donné"""
        Sauvegarde.save(self.cam_to_dic(), chemin + self.nom + ".json")

    def get_camera(chemin:str) :
       """obtient les parametres de la camra dans le présent dans le dossier donné"""
       return CameraParametre.from_dic(Sauvegarde.load(chemin))

# Code complet sur GitHub

Comme vous pouvez le voir, Nous faisons quelques fois appel à la Classe "Sauvegarde".

Cette classe contient uniquement des fonctions et procédures statiques.

Permettant de créer des fichiers JSON à partir d'un dictionnaire, ou d'obtenir un fichier à partir de son nom.

class Sauvegarde() :
    """permet de stocker une valeur à clef dans un fichier JSON"""
    # Cette classe contient des fonctions statiques qui permettent de stocker et récupérer de s valeurs dans un fichier JSON
    def save(donnee, path):
        with open(path, "w") as json_file:
            json.dump(donnee, json_file, indent=4)

    def load(path):
        with open(path, "r") as json_file:
            data = json.load(json_file)
        return data

Calibration d'une caméra

Une caméra permet de capturer un phénomène physique (la lumière) en donnée digitale. Malheureusement, le procédé du fonctionnement d’une caméra induit une inexactitude de ce à quoi l’image ressemble réellement à la réalité (distorsion, colorimétrie).

C’est ainsi que nous allons voir à partir de plusieurs images, comment nous pouvons en extraire des paramètres afin de calibrer la caméra et de réajuster ces images.

Paramètres d'une caméra

Une caméra peut être décrite avec plusieurs variables, que l’on appelle paramètre.

Ces paramètre se distinguent en deux catégories.

Les premiers sont les paramètre intrinsèques, qui sont les paramètres relatif à la caméra, les voici :

  • Le centre optique, c'est le point théorique sur la caméra où les rayons lumineux convergent.
  • La distance focale, est la distance entre le centre optique et l'objet sujet nécessaire à avoir une image nette.
  • Les coefficients de distorsion, qui décrivent la distorsion radiale et tangentielle d'une image.

La deuxième catégorie sont les paramètres extrinsèques, qui correspondent à des valeurs inhérentes à l’image prise par la caméra. Ce sont donc des valeurs qui varient en fonction de la prise :

  • Un vecteur de translation, qui représente la position de la caméra par rapport au sujet plan.
  • Un vecteur de rotation, qui représente la rotation de la caméra par rapport au sujet plan.


Nous voulons donc dans un premier temps trouver ces paramètres.

Pour trouver ces paramètre, nous allons faire ce que l'on appelle un calibrage.

Calibrage

Pour faire un calibrage, nous allons prendre un motif connu.

Car avoir un motif que l'on connaît va nous permettre de "trouver les différences" entre l'image et la réalité, afin d'en extraire des paramètres.

Puisque si l'on sait qu'une ligne est droite dans la réalité mais qu'elle est courbée dans l'image, c'est qu'il y a eu une distorsion radiale.

Ou alors si deux lignes de même longueurs apparaissent de taille différentes, c'est que le plan est incliné par rapport à la caméra.

Nous allons donc prendre un damier. L'avantage du damier est que nous connaissons le nombre de carreaux et nous savons que ces carreaux sont bien droits (sans courbure).

Nous allons maintenant prendre en photo ce damier sous différents angles, ce qui va permettre à OpenCV d'estimer les paramètres de la caméra.

Voici les photos que j'ai prises :


Noah cuneo damier 0.jpg Noah cuneo damier 1.jpg Noah cuneo damier 2.jpg


Notons que plus nous allons donner d'images à OpenCV, plus les paramètres que l'on va obtenir vont être précis.

Mais que pour des soucis de performance, j'en ai fait que 3.

Maintenant que nous avons nos images, nous pouvons appelé ret, corners = cv.findChessboardCorners(gray, (7, 7), None)

Si ret vaut True après l'appel de cette fonction, c'est que OpenCV à trouvé le damier.

Dans ce cas, nous pouvons ajouter dans une liste les points objets (position des corner dans l'espace 3D) et les points images (ces même points sur l'image donc en 2D).

Notons qu'à partir de là, comme openCV à trouvé les carrefours de l'échiquier, nous pouvons tracé les lignes "censés" être droite directement sur l'échiquier. Ce qui nous permet avec un petit coup d'oeil de déterminer si notre image admet de la distorsion :


Noah cuneo trace damier.png


On a bien les lignes colorés superposés sur les lignes droites de mon damier. Malheureusement (ou heureusement) ma caméra n'admet pas tant de distorsion que ça donc on n'observe rien de particulier.

A partir de maintenant, nous pouvons appeler ret, mtx, dist, rvecs, tvecs = cv.calibrateCamera(objpoints, imgpoints, gray.shape[::-1], None, None)

Cette fonction est la fonction d'OpenCV qui va calculer les paramètres de la caméra.

Elle renvoi donc :

  • ret : True ou False en fonction de si la calibration a fonctionnée.
  • mtx : Appelé "Matrice de Caméra" Elle contient les paramètres intrinsèques, sauf les coefficients de distortions.

  • dist: Contient les coefficients de distortions.
  • tvecs : Contient une liste de de vecteur, représentant la translation de la caméra par rapport à chaque image.
  • rvecs : Contient une liste de de vecteur, représentant la rotation de la caméra par rapport à chaque image.

Notons que tvecs et rvecs sont généralement par la suite condensé dans ce qu'on appelle une matrice de projection, mais nous y reviendrons plus tard.

Pour la suite, étant donné que ma caméra ne fait pas subir de grosse distorsion à mes images. Je vais utiliser des images prisent d'internet pour illustrer mes propos.


Image de damier bien distordue, Prise sur internet


Grâce à ça, nous pouvons reconstruire l'image, sans la distorsion et autre effets de découlant la caméra.

Mais subsiste un problème. Lorsque nous allons dé-distosionner l'image, des zones noirs vont apparaître car l'image recourbé va laisser place à du vide.

Nous allons donc recalculer une matrice de caméra, spécifiquement conçu pour la résolution de l'image sans le noir.

newcameramtx, roi = cv.getOptimalNewCameraMatrix(camera.mtx, camera.dist, (w,h), alpha, (w,h))

Nous pouvons avec cette nouvelle matrice, dé-distorsionner l'image grâce au code : dst = cv.undistort(img, camera.mtx, camera.dist, None, newcameramtx)


À gauche, image dé-distortionnée avec la nouvelle matrice. À droite avec l'ancienne

Nous voyons donc bien l'importance de la calibration. Sans elle nous travaillerons sur des images complètement infidèles à la réalité.

Matrice de projection

Je vous parlais précédemment des paramètre intrinsèques et extrinsèques d'une caméra. Eh bien en réalité il est rare qu'on utilise ces données de façon brute.

En effet, nous allons généralement plutôt les condenser dans ce qu'on appelle une matrice de projection (noté P).

Une matrice de projection est donc une matrice qui contient les paramètres intrinsèques et extrinsèques d'une caméra.

Ce qui permet de savoir comment se place la caméra dans l'espace 3d + les distorsions induite par la caméra.

Nous obtenons donc cette matrice en convertissant d'abord les rotation en Quaternion en Rodrigues.

Puis en faisant le produit matricielle de la matrice de caméra, et la matrice recomposé des paramètres extrinsèques

Cette matrice ayant 12 degrés de liberté, lesquels sont les 3 axes de rotation (* 3 car en Rodrigues) et les 3 axes de translation.

Les degrés de liberté étant des paramètres indépendants nécessaires pour spécifier complètement l'état d'un système.

Voyons voir maintenant, comment à partir des paramètres que l'on a extrait précédemment, comment nous pouvons établir cette matrice de projection.

matrice_rotation, _ = cv.Rodrigues(rvecs[i])
proj = np.dot(mtx, np.hstack((matrice_rotation, tvecs[i])))
matrices_proj.append(proj)

On pourrait maintenant à partir de cette matrice, faire de la réalité augmentée dans le cas où nous avons un motif prédéfinis tel que le damier.

Maintenant que nous avons vu les différentes étapes de calibration d'une caméra, voyons ce quels traitements nous pouvons donner aux images.

*Le code complet des 3 dernière sous parties se trouvent dans main.py sur GitHub.

Analyse et traitement d'une image

Tout d'abord, qu'est ce qu'une image.

Eh bien une image est juste une représentation visuelle, de données numérique plus complexe.

Ces données numérique varient en fonction du format de l'image. Mais contiennent en général des informations sur la résolution de l'image, la valeur RGB de chaque pixel et encore d'autres information.

Ainsi, comment l'ordinateur peut-il trouver des caractéristiques définissant une image afin de lui apporter des modifications ?

Nous allons pour cela, tenter de reconstituer un panorama à partir de ces deux images :

Noah cuneo panorama0.jpg Noah cuneo panorama1.jpg

Trouver des descripteurs avec SIFT

Alors, comment faire pour trouver des caractéristiques déterminant l'image ?

Pour cela il existe quelque méthode sur OpenCV tel que SIFT, ORB ou encore SURF. Qui sont des méthodes permettant d'extraire les points clés d'une image.

Nous allons nous concentrer sur SIFT.

Dans un premier temps, SIFT va essayer de trouver des points qui pourrait être résistant à des transformation tel que des rotation et translation.

Pour ce faire, SIFT va dans un premier temps passer l'image en noir et blanc. Et à partir de cette images, va générer des copies avec des paramètres différents :

  • L'algorithme va faire varier la différence de Gauss (DOG), qui va faire ressortir les contours. On génère cela en appliquant un flou Gaussien sur l'image.
  • L'algorithme va également altérer l'image en réduisant la résolution de l'image, avec N octaves.

N octaves signifiant que l'on va réduire l'image de 1 / 2 à 1 / 2^N. Donc une image de largeur 1024 sera réduite à 512, 256, etc...

Pour bien comprendre ces transformations, j'ai codé un algorithme qui va générer toutes ces variations d'images.


def difference_gaussienne(image, n_octaves, n_scales):
    """Cette fonction sert à comprendre comment SIFT fait pour trouver des points clés résistants
    On va altérer l'image avec différentes résolutions et différents niveau de flou, permettant d'identifier les points clés résistants.
    Je stock toutes les images de différent niveau de gauss et de résolution dans le dossier gauss"""
    image_grayscale = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    image_grayscale = np.float32(image_grayscale)
    gaussian_images = []

    k = 2 ** (1 / n_scales)
    if not os.path.exists("gauss"):
        os.makedirs("gauss")

    for octave in range(n_octaves):
        sigma = 1.6
        octave_images = []
        for i in range(n_scales + 3):
            image_blurred = cv2.GaussianBlur(image_grayscale, (0, 0), sigma)
            octave_images.append(image_blurred)
            sigma *= k

        gaussian_images.append(octave_images)
        image_grayscale = cv2.resize(image_grayscale, (image_grayscale.shape[1] // 2, image_grayscale.shape[0] // 2))

    dog_images = []
    for octave_idx, octave_images in enumerate(gaussian_images):
        octave_folder = os.path.join("gauss", f"octave_{octave_idx}")
        os.makedirs(octave_folder, exist_ok=True)

        dog_octave = [octave_images[i + 1] - octave_images[i] for i in range(len(octave_images) - 1)]
        for scale_idx, dog_image in enumerate(dog_octave):
            cv2.imwrite(os.path.join(octave_folder, f"scale_{scale_idx}.jpg"), cv2.normalize(dog_image, None, 0, 255, cv2.NORM_MINMAX))
        dog_images.append(dog_octave)

    return dog_images

# Code complet disponible sur GitHub dans le fichier Gauss.py

Ce code m'a permis de générer toutes les variations de l'image de l'immeuble. J'ai ensuite assemblé toutes les images sur un logiciel de traitement pour bien voir les différentes transformations.


Noah cuneo gauss octave.png


Comme nous pouvons le constater, sur certaines variation de l'images des points ressortent (Toits de voiture, motifs sur les fenêtres, etc...).

Et c'est comme ça que SIFT va sélectionner ses points clés. C'est à dire en regardant où la différence de niveau de gris est la plus grande.

Maintenant, comment faire pour décrire ces points clés ?

On pourrait penser qu'il suffit de conserver les valeurs RGB des zones autours de points clés.

Mais cette méthode ne prend pas en compte les différences de luminosité, rotation et translation possibles entre les deux images.

SIFT va donc plutôt utiliser ce qu'on appelle un histogramme de gradient orienté (HOG).


Pour faire court, un histogramme de gradient orienté est un vecteur de taille 9, qui contient l'information de la direction et magnitude du gradient d'une cellule.

Une cellule (ou patch) étant une partie de l'image dont la taille est prédéfinie. En général on prend des cellules de 8x8 pixels.

Mais voyons pas à pas comment est construit cet histogramme de gradient orienté.

Dans un premier temps, on va donc diviser l'image en une multitude de patch.

Ensuite, nous appliquons deux filtres de SOBEL sur l'image : Un horizontal et un vertical. Ce qui va permettre de faire ressortir les contours dans l'image.

Noah cuneo sobel x.jpg Noah cuneo sobel y.jpg

Nous pouvons maintenant parcourir chaque pixel de chaque patch pour trouver :

  • La direction du gradient : C'est la direction vers laquelle le changement d'intensité est le plus grand depuis le pixel.

Ce seront des angles non signés, c'est à dire que on aura un angle entre [0°, 180°]. On peut la calculer θ = arctan2(Gx, Gy) Avec Gx le pixel sur le filtre de Sobel horizontal, et Gy sur le vertical.

  • La magnitude du gradient : il représente la norme du gradient. Plus la variation d'intensité est grande, plus la magnitude le sera.

On peut la calculer M = sqrt(Gx ** 2 + Gy ** 2)

Voici un schéma, pris sur internet, qui exprime bien visuellement les valeurs que nous possédons en l'état.


Noah cuneo hog0.png


Comme vous pouvez le voir. On a bien sur chaque pixel une flèche, dont la direction représente la direction du gradient et la norme la magnitude.

Il nous reste maintenant à calculer l'histogramme de ce patch.

Pour ce faire, nous allons construire un histogramme de 9 bins. Chaque bins étant un multiple de 20 de 0 à 160. Qui est une répartition de l'angle.

Nous allons maintenant pour chaque pixel du patch, regarder la valeur de la direction de son gradient. Puis nous regardons entre quels bins cette valeur se trouve.

Puis nous regardons la magnitude de ce pixel, et nous répartissons cette magnitude sur les deux bins borne en faisant une interpolation linéaire.

Par exemple, si un pixel a une direction de 10° et une magnitude de 70, il est à 50% de la distance du bins 0 et 20. Donc on donne 35 (50% de 70) au bins 0, et 35 au bins 20.


Noah cuneo hog1.png


C'est donc grossièrement comme cela, que SIFT va décrire les points d'intérêts d'une image.

Mais à présent, maintenant que nous avons nos points d'intérêts sur nos deux images. Comment déterminer la relation géométrique entre ces deux images ?

C'est ce que nous allons voir.


Construction du panorama par homographie


Pour reconstruire notre panorama, il faut donc que nous sachions quels transformations géométriques permettent de passer une d'une image à la perspective de son homologue.

Nous appelons la relation géométrique entre ces deux images une homographie.

Une homographie est représenté par une matrice d'homographie noté H de forme 3x3 :


Avec ici chaque élément de la matrice représentant un paramètre de la transformation géométrique. Sauf le h33 qui est l'élément de normalisation de la matrice souvent égal à 1.

C'est donc une matrice avec 8 degrés de liberté.

Voici donc, comment avec les points clés des deux images nous permettent de calculer cette matrice :

H, _ = cv.findHomography(pts_src, pts_dst, cv.RANSAC, 5.0)

Avec :

  • pts_src et pts_dst étant les points clés des deux images.
  • <cv.RANSAC> Qui est un algorithme d'élimination de points aberrants que nous verrons juste après.

Maintenant que nous savons quelles relations géométriques unit nos deux images que pouvons nous faire ?

Et bien pas mal de chose ! Par exemple, nous pouvons aisément transformé l'image 1 du bâtiment pour qu'elle soit à la perspective de l'image 2 :

Noah cuneo panorama0.jpg Noah cuneo panorama1.jpg

Photo d'immeuble 1 remise à la perspective de l'image 2


Cela a été possible grâce à la méthode warpPerspective fournie par OpenCV :

hauteur, largeur, _ = img2.shape
img1_perspective = cv.warpPerspective(img1, H, (largeur, hauteur))

Avec cette matrice d'homographie, nous pouvons également tracer les lignes épipolaires.

Les lignes épipolaires sont les lignes fictive, reliant le centre optique de la caméra aux points de l'image 1 que l'on va tracer sur l'image 2.

Voici un exemple plus concret avec nos images d'immeuble :


Ligne épipolaires


Ces tracés sont utiles pour décrire visuellement le lien géométrique entre les deux images.

Voici par ailleurs, un exemple où l'épipôle est à l'infini à cause en l'occurrence d'une translation sur l'axe z.


Ligne épipolaires vers l'infini


Maintenant, revenons à notre panorama.

Nous pourrions penser que pour reconstruire un panorama avec nos deux images il suffirait d'utiliser warpPerspective sur une des deux images puis de les assembler non ?

Alors oui et non. Car en plus de devoir mettre une image à la perspective de l'autre, il va falloir que nous les assemblions de tel sorte à ce que leur points de correspondance se supperposent.

Voici une des fonctions que j'ai codé me permettant justement de bien concaténer ces deux images en superposant le points de correspondance.

Cette fonction va aussi nous afficher avec des Gizmos quels points de correspondance sont utilisés pour la superposition.

def relie_point_correspondance(img1, keypoints1, img2, keypoints2, matches):
  """Ce script permet d'entourer et de relier tous les points de correspondance entre deux images"""

  # On créer une image vide permettant d'accueilir les deux images.
  r, c = img1.shape[:2]
  r1, c1 = img2.shape[:2]
  output_img = np.zeros((max([r, r1]), c+c1, 3), dtype='uint8')
  output_img[:r, :c, :] = np.dstack([img1, img1, img1])
  output_img[:r1, c:c+c1, :] = np.dstack([img2, img2, img2])

  # On parcours tous les points de correspondance, et on entoure chacun d'eux tout en les reliant à leurs homologues.
  for match in matches:
    img1_idx = match.queryIdx
    img2_idx = match.trainIdx
    (x1, y1) = keypoints1[img1_idx].pt
    (x2, y2) = keypoints2[img2_idx].pt

    cv2.circle(output_img, (int(x1),int(y1)), 4, (0, 255, 255), 1)
    cv2.circle(output_img, (int(x2)+c,int(y2)), 4, (0, 255, 255), 1)

    cv2.line(output_img, (int(x1),int(y1)), (int(x2)+c,int(y2)), (0, 255, 255), 1)
    
  return output_img

Voici donc les points qui vont servir à la superposition (correspondance entourées en rose sur la droite de l'image) :


Photo d'immeuble 1 remise à la perspective de l'image 2


Grâce à l'alliage de cette fonction et de warpPerspective nous pouvons ainsi reconstruire notre panorama.

(Code de la reconstruction de panorama disponible sur panorama.py du projet GitHub)


Panorama final


Nous avons un magnifique panorama ! Maintenant, nous allons rapidement expliquer l'algorithme de RANSAC et ce à quoi il sert.


Élimination des points aberrants avec RANSAC


RANSAC nous a permis de retrouver la relation géométrique des images tout en retirant les points aberrants.

Mais tout d'abord un peu de vocabulaire :

  • Un inlier est un point de correspondance correct entre deux images.
  • Un outlier est un points de correspondance incorrect ou bruité. On les appelle points aberrants.

Voici donc comment fonctionne RANSAC :

Dans un premier temps, l'algorithme va prendre un groupe de points aléatoires.

Puis il va chercher quel transformation géométrique permet d'obtenir ces points sur la deuxième image. On appelle ça "établir un modèle".

A partir de ce modèle, on compte le nombre de points qui correspondent.

On recommence les étapes précédentes un certains nombre de fois, puis on sélectionne le modèle qui a permis d'avoir le plus de points de correspondance.

Et bien sûr on élimine les points qui n'ont strictement aucune correspondance.

Ce qui permet d'obtenir l'homographie entre les deux images tout en éliminant les outliers et conservant les inliers.

Voici une image prise chez researchgate.net qui selon moi illustre le mieux le fonctionnement de RANSAC :


Image explicative de RANSAC


Par exemple sur l'image, il est clair que le modèle numéro 2 est le plus viable. Il présente le plus de points de correspondance.

Nous en avons terminé sur le chapitre traitement des images. Nous allons ainsi voir, comment à partir d'image nous pouvons établir un modèle 3D.

Reconstruction 3D

Nous allons maintenant nous intéresser à la reconstruction 3D. Qui est un domaine très important notamment dans le monde de la cinématographie ou dans le monde du jeu vidéo.

Nous pourrons ainsi comprendre, comment le Titanic à été remodélisé grâce à des photos pour le film éponyme.

Nous allons dans notre cas essayer de reconstruire cette objet.

Comment estimer la profondeur

Lorsque nous prenons un objet en photo sous plusieurs angle. Comment faisons nous pour dissocier l'objet au premier plan de l'arrière plan.

C'est à dire, comment pouvons nous estimer la distance de chaque points de l'image à la caméra.

Pour répondre à cette question, observons un phénomène de la vie courante.

Vous prenez le train et regardez par la fenêtre. Que remarquez vous ?

Et bien l'on remarque que plus un objet est proche, plus il va défiler rapidement devant nous. (arbres, maisons)

Tout comme plus l'objet est loin, plus il restera longtemps dans notre champs de vision. (montagnes, nuages)

Ce concept s'appelle la parallaxe. Nous pouvons donc nous baser sur ce dis concept pour estimer la distance de chaque objet dans la scène.

Par exemple, en prenant deux photos dont la seule différence est une translation de la caméra sur l'axe x.

Nous trouvons ainsi que les objets ayant le plus "bougé" sur l'axe x sont donc plus proche de la caméra.

Voici la formule qui décrit ce que nous venons d'expliquer : 'disparity = x − x′ = Bf / Z'.

Cette formule signifie juste que la profondeur d'un point est inversement proportionnelle à la distance sur l'axe x de ce même points entre deux images.

C'est ce concept là qui va nous permettre de déterminer l'objet au premier plan que nous souhaitons donc reconstruire.

Nous allons donc avec OpenCV construire ce que nous appellons "Carte de profondeur" ou "Depth map' en anglais.

Voici comment nous pouvons le faire :

    stereo = cv2.StereoSGBM_create(
        minDisparity=disparite_min,
        numDisparities=nb_disparites,
        blockSize=taille_bloc,
        uniquenessRatio=5,
        speckleWindowSize=5,
        speckleRange=5,
        disp12MaxDiff=2,
        P1=8 * 3 * taille_bloc ** 2,
        P2=32 * 3 * taille_bloc ** 2
    )
    disparite = stereo.compute(img_gauche, img_droite)
    carte_profondeur = cv2.convertScaleAbs(disparite, alpha=255/disparite.max())
    cv2.imshow('Carte de profondeur', carte_profondeur)

Comme nous le voyons, nous initialisons un objet nommé StereoBGM. Qui permet d'établir les paramètres qui vont nous servir à construire la carte de profondeur.

Je vais expliquer l'utilité des 3 paramètres principaux :

  • minDisparity. C'est le nombre de bloc minimum sur l'axe horizontal entre la position de la paire de points correspondants des deux images.
  • numDisparities. C'est le nombre de blocà partir du minimum, qui pourraient correspondre à la distance de la paire de points. Donc en fait l'algorithme va regarder dans la plage [minimumDisparities, minimumDisparities + numDisparities] pour trouver la distance en bloc entre les deux points de correspondance.
  • blockSize. C'est tout simplement la taille du bloc analysé, une taille n signifie que l'on analyse des blocs de taille nxn.

Pour le test, j'ai donc pris en photo un flacon de déodorant en admettant un léger décalage de la caméra sur l'axe x entre les deux images.


Noah cuneo left.jpg Noah cuneo right.jpg


Par tâtonnement, j'ai fini par trouver les paramètres donnant la meilleure carte de profondeur


Noah cuneo depthmap.png


On reconnaît donc effectivement la forme du flacon et de ma main. Avec la couche noir étant le fond de la scène et la couche blanche l'objet de la scène.

La depth map n'est clairement pas optimale avec des trous partout. Mais elle est la meilleure que j'ai pu obtenir en faisant varier les paramètres vu ci-dessus.

Cette méthode est pas mal pour estimer grossièrement les différentes couches d'une image.

Elle est utile dans plusieurs domaine tel que la photographie, notamment pour appliquer un flou seulement sur le fond.

Ou encore dans le contexte de la vision par ordinateur pour détecter des obstacles ou encore en reconstruction dans le but de savoir quel groupe de pixel nous intéresse.

Mais nous allons maintenant voir comment obtenir un nuage de points, représentant l'objet grâce à des images prisent de perspectives différentes.

Reconstruction par triangulation

Avant toutes choses, nous allons définir le concept de matrice essentielle et fondamentale.

Une matrice fondamentale (notée F) est une matrice qui va décrire la relation épipolaire entre deux images tout en prenant en compte les paramètres de calibration de la caméra. Voici sa forme :

Cette matrice est en quelque sorte "soeur" de la matrice essentielle, qui elle décrit la relation épipolaire entre deux images mais sans les paramètres de calibration de caméra.

Ce qui signifie que l'on peut obtenir la matrice essentielle en normalisant les coordonnées de la matrice fondamentale par ses paramètre de calibration.

Cette matrice (notée E) a donc une forme similaire :

Voici dans un premier temps comment on va estimer la matrice essentielle, ce qui va dans un premier temps nous permettre de comprendre la relation épipolaire entre les deux images.

E, mask = cv2.findEssentialMat(pts1, pts2, K, method=cv2.RANSAC, prob=0.999, threshold=1.0)

_, R, t, _ = cv2.recoverPose(E, pts1, pts2, K, mask=mask)

P1 = np.hstack((np.eye(3), np.zeros((3, 1))))
P2 = np.hstack((R, t))

P1 = K @ P1
P2 = K @ P2

pts1_filtered = pts1[mask.ravel()==1]
pts2_filtered = pts2[mask.ravel()==1]

A partir de là nous pouvons faire ce qu'on appelle de la triangulation des points.

Un peu lors d'une cartographie, nous allons à partir de la relation épipolaire des images estimer la profondeur de chaque points et ainsi reconstituer les points dans trois dimensions.

On pourra ainsi obtenir un nuage de points.

Pour cela, il suffit d'utiliser la fonction d'OpenCV points_4D = cv2.triangulatePoints(P1, P2, pts1_filtered, pts2_filtered)

Nous pouvons dès à présent afficher le nuage de points en utilisant matplotlib.

Pour tester ma fonction de reconstruction, j'ai pris différents sets d'images jusqu'à obtenir un résultat satsfaisant.

Voici les différents couples d'images que j'ai pris.


Noah cuneo reco0A.jpg Noah cuneo reco0B.jpg Noah cuneo reco1A.jpg Noah cuneo reco1B.jpg Noah cuneo reco2A.jpg Noah cuneo reco2B.jpg


Regardons dans un premier temps si les points de correspondances de chaque set est suffisant pour la reconstruction.


Noah cuneo reco0.png


Le problème avec ce set, c'est que les reflets de la bouteille implique qu'il y a beaucoup de points aberrants. Et donc en réglant RANSAC on obtient peu de points, inintéressants dans le contexte de reconstruction.


Noah cuneo reco1.png


Ce set à première vue n'a pas de problème, excepté le nombre limité et inintéressant de points. Cela s'explique par la rotation trop violente de la caméra autour de l'objet. Trop violente pour que SIFT repère les bons points de correspondance.


Noah cuneo reco2.png


Ce set n'a à priori pas de problème. Il manque quelque points définissant la forme de l'objet mais nous trouverons un moyen de palier ce problème juste après.

Observons le nuage de points obtenu avec ce set.


Noah cuneo nuage nul.png


On peut voir que c'est pas mal, mais ce n'est pas parfait. Baissons le seuil de Ransac pour réduire le nombre d'outliers.


Noah cuneo nuage cool0.png Noah cuneo nuage cool1.png


On commence à voir apparaître la forme caractéristique de pavé du livre.

Pour mieux définir les formes du livres, il faudrait améliorer mon algorithme pour qu'il puisse prendre en charge plus de deux image.

La reconstruction Next Gen

Nous avons vu que la tâche de reconstruction 3D peut parfois être ardue. Cependant, il existe de nouvelle méthode qui pourrait mener à un gain de temps dans la réalisation de modèle à partir d'image.

Cette méthode est la reconstruction à partir d'un réseau neuronal.

Il existe par exemple le modèle PIFuHD qui permet de reconstruire des modèles 3d d'humain seulement à partir de photos.

Voici comment fonctionne grossièrement ce modèle :

  • Un réseau de neurones extrait des caractéristiques de l'image pour chaque pixel.
  • Le réseau neuronale indique pour chaque caractéristique si le points en question est dans la silhouette du corps.
  • La technique de marching cubes convertit les caractéristiques en modèle 3d avec maillages triangulaire, créant ainsi un modèle 3D haute résolution du corps humain.

Voici les résultats que promet PIFuHD :


Noah cuneo pifuhd.png


Nous pouvons également tester nous même le code en l'exécutant depuis un notebook en ligne.

Voici une image de moi sur un poteau.


Noah cuneo photo.png


En utilisant cette image comme modèle dans PIFuHD, nous obtenons une normal map de mon corps. Qui simule les détails de la surface d'un modèle 3D en affectant la lumière qui illumine ce modèle.


Noah cuneo normalmap.png


Nous obtenons également un fichier .obj qui est un fichier de modèle 3D. Que nous pouvons modifier par la suite sur un logiciel comme Blender.


Conclusion


Au final, le sujet de la vision par ordinateur bien qu'obscure au premier coup d'œil. Est en réalité la base de multiples algorithme de la vie de tous les jours. Comme la reconnaissance faciale, le fonctionnement d'une Tesla, des filtres Instagram.

C'est un sujet fascinant que l'on se doit de connaître au minimum si l'on veut poursuivre en informatique.

Sources :

  • Wikipedia
  • OpenCV documentation
  • learnopencv.com
  • reasearchgate.net
  • shunsukesaito.github.io/PIFuHD