Calibration de caméra et reconstruction 3D
Noah CUNEO
Tuteur : Stéphane BREUILS
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.
On é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 :
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.
Organisation du projet sur GitHub
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 :
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).
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.
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)
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 COMPLETE CETTE PARTIE PLUS TARD
Maintenant que nous avons vu comment calibrer 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 :
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.