Ce document montre comment piloter une caméra Basler USB 3.0 avec un script Python, au moyen de l'interface pypylon. Celle-ci est une réplique de l'API Pylon C++, documentée dans pylon C++ Programmer's Guide.
Les caméras Basler sont adaptées à l'usage scientifique pour trois raisons :
Installation :
Nous utilisons la caméra aca1300-200uc, mais tout autre modèle devrait fonctionner de la même manière. Toutes les expériences exposées dans ce site et faisant appel à un enregistrement vidéo ont été réalisées avec cette caméra.
Avant de programmer un script Python, il est important de bien se familiariser avec le fonctionnement de la caméra au moyen du logiciel Pylon Viewer (qui fait partie de Pylon Software Suite). Ce logiciel permet d'accéder à tous les réglages de la caméra et d'enregistrer des vidéos mais il a l'inconvénient de ne pas implémenter de compression vidéo efficace et, en conséquence, le nombre d'images pouvant être enregistrées dans un fichier vidéo est trop petit pour beaucoup d'applications.
Nous décrivons les paramètres les plus importants, dont nous montrons plus loin la programmation via l'interface pypylon.
L'onglet Image Format Control permet de régler la taille de l'image. Par défaut, la totalité du capteur est utilisée mais il est possible de définir une fenêtre de taille plus petite et de positionner cette fenêtre réduite sur le capteur. La réduction de la taille d'image permet d'augmenter la fréquence d'acquisition (sous réserve que le temps d'exposition soit assez petit, comme expliqué plus loin). Les paramètres importants sont :
Ces quatres paramètres sont des nombres entiers multiples de 16. Le changement de la taille de la fenêtre ne peut se faire que lorsque l'acquisition est arrêtée.
L'onglet Acquisition Control permet de régler les paramètres temporels de l'acquisition :
L'onglet Image Quality Control permet de régler le calcul des couleurs. Le paramètre le plus utile est Balance White Auto qui permet d'activer ou de désactiver le réglage automatique de la balance des blancs, soit en continu avec le mode On soit une seule fois avec le mode Once. La balance des blancs permet d'obtenir un rendu naturel des couleurs lorsqu'on utilise une lumière artificielle, ce qui est presque toujours le cas lors d'une expérience réalisée en laboratoire. Si la balance des blancs automatique ne fonctionne pas bien (ce qui est rare), le paramètre Light Source Preset permet de choisir la température de couleur de la source. Si l'on doit réaliser plusieurs vidéos à des jours différents, ce réglage est peut-être préférable au réglage automatique si l'on vise un rendu constant des couleurs. Remarquons que le mélange de différentes sources de lumière (par exemple la lumière du jour diffusée par les nuages mélangée avec celle d'une lampe à incandescence) rend difficile, voire impossible, la balance des blancs.
Le script suivant montre comment programmer une caméra Basler au moyen de l'interface pypylon. Il comporte une boucle d'acquisition des images avec un traitement au moyen de l'interface de programmation OpenCV qui peut être ajouté.
Les paramètres de configuration de la caméra sont accessibles via leurs noms tels qu'ils apparaissent dans Pylon Viewer mais sans les espaces. Par exemple, le paramètre Exposure Time est accessible par le nom ExposureTime.
from pypylon import pylon import cv2 as cv import numpy as np
On commence par se connecter à la caméra et par l'ouvrir :
camera = pylon.InstantCamera(pylon.TlFactory.GetInstance().CreateFirstDevice()) camera.Open() nodemap = camera.GetNodeMap()
L'objet nodemap permet d'accéder aux paramètres de la caméra par leurs noms. Beaucoup de ces paramètres sont accessibles à la fois en lecture et en écriture. Lorsqu'on modifie un paramètre, il reste en mémoire après la fin du script, probablement stocké dans un fichier géré par le pilote (moins probablement stocké dans la caméra elle-même). Les lignes suivantes affichent la taille de l'image, qui est celle enregistrée, puis configure une nouvelle taille (ici on utilise la totalité du capteur) :
width = nodemap.GetNode("Width")
height = nodemap.GetNode("Height")
print("width = %d"%width.GetValue())
print("height = %d"%height.GetValue())
w = 1280
h = 1024
width.SetValue(w)
height.SetValue(h)   
				   
				   La largeur et la hauteur doivent être multiples de 16, sinon une erreur est déclenchée. Voici le choix du décalage de la fenêtre, à faire lorsque celle-ci est réduite et qui doit aussi être multiple de 16 :
offsetX = nodemap.GetNode("OffsetX")
offsetY = nodemap.GetNode("OffsetY")
offsetX.SetValue(0*16)
offsetY.SetValue(0*16)
				   
				   Voici le réglage du temps d'exposition, éventuellement en automatique :
autoexp = False
exptime = 10000 # en microsecondes 
exposureAuto = nodemap.GetNode("ExposureAuto")
if autoexp:
	exposureAuto.FromString("Once") # réglage automatique de l'exposition : une seule fois
else:
	exposureAuto.FromString("Off")
	exposureTime = nodemap.GetNode("ExposureTime")
	exposureTime.FromString("%d"%exptime)
				   
				   Remarquons que la configuration du temps d'exposition se fait par une chaîne de caractères (la fonction setValue n'existe pas pour cette propriété).
Voici le réglage de la balance des blancs :
balanceWhiteAuto = nodemap.GetNode("BalanceWhiteAuto")
balanceWhiteAuto.FromString("Once")			   
				   
				   Voici l'activation de l'échantillonnage temporel et de la fréquence d'acquisition :
framerate = 30 # en Hz
nodemap.GetNode("AcquisitionFrameRateEnable").FromString("1")
nodemap.GetNode("AcquisitionFrameRate").FromString("%d"%framerate)
framerate = nodemap.GetNode("ResultingFrameRate").GetValue()
print("Framerate = %f"%framerate)
				   
				   L'opération suivante consiste à créer un convertisseur d'image et à le configurer pour générer des images au format OpenCV (images BGR 8 bits par couche) :
converter = pylon.ImageFormatConverter() converter.OutputPixelFormat = pylon.PixelType_BGR8packed converter.OutputBitAlignment = pylon.OutputBitAlignment_MsbAligned
On crée une fenêtre OpenCV pour afficher les images :
cv.namedWindow('basler', cv.WINDOW_NORMAL)
				   
				   La commande suivante démarre l'acquisition :
camera.StartGrabbing(pylon.GrabStrategy_LatestImageOnly)
Avec la stratégie GrabStrategy_LatestImageOnly, seule la dernière image sera lue. En conséquence, si la lecture se fait à une fréquence inférieure à la fréquence d'acquisition (AcquisitionFrameRate), des images seront perdues. Ce mode est adapté au traitement en temps réel : l'image lue et traitée est toujours la plus proche de l'instant présent.
Voici la boucle qui effectue la lecture des images, les convertit au format OpenCV et les affiche dans la fenêtre. L'appuie sur la touche q (quit) permet de sortir de la boucle.
while camera.IsGrabbing():
	timeout = 10000 # ms, temps d'attente maximal de l'image
	grabResult = camera.RetrieveResult(timeout, pylon.TimeoutHandling_ThrowException)
	if grabResult.GrabSucceeded():
	    skip = grabResult.GetNumberOfSkippedImages()
		if skip!=0: print("skip : %d"%skip)
		image = converter.Convert(grabResult)
		img = image.GetArray() # image au format OpenCV (BGR), tableau np.ndarray
		# placer ici le traitement d'image 
		cv.imshow('basler', img)
		k = cv.waitKey(1)
		if k == ord('q'):
			break
	grabResult.Release()
    
camera.StopGrabbing()
camera.Close()
cv.destroyAllWindows()				
					
					Dans le mode GrabStrategy_LatestImageOnly, il n'y qu'un seul tampon pour stocker les images délivrées par la caméra. En conséquence, si l'appel camera.RetrieveResult n'est pas fait à une fréquence assez grande, des images seront perdues. Le nombre d'images perdues est donné par grabResult.GetNumberOfSkippedImages(). L'appel grabResult.Release() informe le pilote que le tampon qui a servi à stocker la dernière image peut être réutilisé pour une nouvelle image. Le premier argument de la fonction camera.RetrieveResult définit un temps d'attente maximal (ici 10 s). Si ce délai est dépassé, l'action à réaliser est indiquée par le deuxième argument (pylon.TimeoutHandling_ThrowException ou pylon.TimeoutHandling_Return). Dans le cas présent, il n'y a a priori aucune raison que le délai soit atteint.
Le script précédent est modifié afin d'effectuer l'enregistrement de la vidéo dans un fichier, avec compression. Le script peut enregistrer une vidéo quelle que soit la fréquence d'acquisition.
from pypylon import pylon
import cv2 as cv
import numpy as np
camera = pylon.InstantCamera(pylon.TlFactory.GetInstance().CreateFirstDevice())
camera.Open()
nodemap = camera.GetNodeMap()
width = nodemap.GetNode("Width")
height = nodemap.GetNode("Height")
print("width = %d"%width.GetValue())
print("height = %d"%height.GetValue())
w = 1280
h = 1024
width.SetValue(w)
height.SetValue(h)
offsetX = nodemap.GetNode("OffsetX")
offsetY = nodemap.GetNode("OffsetY")
offsetX.SetValue(0*16)
offsetY.SetValue(0*16)
autoexp = False
exptime = 9500 # en microsecondes 
exposureAuto = nodemap.GetNode("ExposureAuto")
if autoexp:
	exposureAuto.FromString("Once") # réglage automatique de l'exposition : une seule fois
else:
	exposureAuto.FromString("Off")
	exposureTime = nodemap.GetNode("ExposureTime")
	exposureTime.FromString("%d"%exptime)
balanceWhiteAuto = nodemap.GetNode("BalanceWhiteAuto")
balanceWhiteAuto.FromString("Once")
framerate = 100 # en Hz
nodemap.GetNode("AcquisitionFrameRateEnable").FromString("1")
nodemap.GetNode("AcquisitionFrameRate").FromString("%d"%framerate)
framerate = nodemap.GetNode("ResultingFrameRate").GetValue()
print("Framerate = %f"%framerate)
converter = pylon.ImageFormatConverter()
converter.OutputPixelFormat = pylon.PixelType_BGR8packed
converter.OutputBitAlignment = pylon.OutputBitAlignment_MsbAligned	
camera.MaxNumBuffer = 1000 # taille de la file (nombre de tampons maximal)
camera.StartGrabbing(pylon.GrabStrategy_OneByOne)
fourcc = cv.VideoWriter_fourcc(*'mpg1')
videoWriter = cv.VideoWriter('video.mpg',fourcc,30,(w,h))
f = 0
numFrames = 1000
while camera.IsGrabbing() and f < numFrames:
	timeout = 10000 # ms, temps d'attente maximal de l'image
	grabResult = camera.RetrieveResult(timeout, pylon.TimeoutHandling_ThrowException)
	if grabResult.GrabSucceeded():
		skip = grabResult.GetNumberOfSkippedImages()
		if skip!=0: print("skip : %d"%skip)
		image = converter.Convert(grabResult)
		img = image.GetArray() # image au format OpenCV (BGR), tableau np.ndarray
		# placer ici le traitement d'image 
		videoWriter.write(img)
		f += 1
	grabResult.Release()
print("%d frames"%f)
camera.StopGrabbing()
camera.Close()
cv.destroyAllWindows()
videoWriter.release()	
				Avec le logiciel Pylon Viewer, une vidéo est enregistrée sans compression (ou avec un taux de compression très faible) : une vidéo de résolution 1280x1024 peut contenir seulement 520 images, soit une durée de 17 secondes si la fréquence est de 30 images par seconde. La taille du fichier AVI est alors de 2 Go (il est impossible de dépasser cette taille). Le codec MPEG1 utilisé dans ce script permet d'enregistrer beaucoup plus d'images (l'ordre de grandeur est 100 fois plus).
Lors de l'acquisition, les images sont stockées dans une file d'attente (FIFO), dont la taille par défaut est de 10 tampons, chaque tampon stockant une image. Afin de pouvoir traiter des fréquences d'acquisition élevées, nous avons augmenté la taille de la file (camera.MaxNumBuffer) et le nombre d'images à acquérir (numFrames) est fixé avant d'entrer dans la boucle. L'affichage des images est enlevée. Dans le mode de lecture GrabStrategy_OneByOne, toutes les images sont stockées dans la file de tampons jusqu'à ce que le nombre maximal de tampon soit atteint. Pour plus d'informations sur les différents modes de saisie (Grab Strategies), consulter Pylon API : Advanced Topics. Dans ce script, le nombre de tampons maximal dans la file est exactement égal au nombre d'images que l'on veut enregistrer. On est donc certain que toutes les images seront bien enregistrées dans le fichier quelle que soit la fréquence d'acquisition. Lorsque la fréquence d'acquisition est grande (ici 100 Hz), la durée de l'appel videoWriter.write(img) peut excéder la période d'échantillonnage. Si le nombre maximal de tampons dans la file est inférieur au nombre d'image à enregistrer, il peut arriver un moment où la file est pleine et donc des images seront perdues. Si Te est la période d'échantillonnage (inverse de framerate), T la durée entre deux appels consécutifs de camera.RetrieveResult, supposée supérieure Te, N la taille de la file (nombre maximal de tampons) et Nf le nombre d'images souhaité, la file est pleine à l'instant t=NT donc aucune image n'est perdue si et seulement si . Si le rapport Te/T (inférieur à 1) est inconnu, la manière la plus simple et la plus sûre de satisfaire cette condition est de choisir N=Nf. Une autre stratégie consisterait à mesurer le rapport Te/T au début de la boucle puis à calculer une valeur convenable de N en conséquence.
La file est stockée en mémoire RAM, puis lorsque celle-ci est quasi pleine, dans le disque SSD. Une file de 1000 tampons de taille 1280x1024 chacun occupe un peu moins de 4 Go de mémoire RAM. L'espace mémoire de la file n'est pas réservé dès le début mais au fur et à mesure de l'enregistrement. Avec un PC possédant 32 Go de mémoire RAM, nous pouvons sans difficultés enregistrer une vidéo de 10000 images à une fréquence de 100 Hz (soit une durée de 100 s).
Remarque : il est possible d'ajouter un traitement d'image (avant l'écriture dans le fichier) mais, lorsque la vidéo est enregistrée dans un fichier, on préfère le plus souvent effectuer le traitement en relisant le fichier. Dans certains cas, il peut être tout de même intéressant de faire une partie du traitement au moment de la prise de vue, afin de s'assurer que les conditions de celle-ci, notamment les conditions d'éclairage, sont bien compatibles avec le traitement prévu. Pour effectuer par exemple un réglage optimal de l'éclairage, on fera plutôt appel au script précédent, qui comporte un affichage en temps réel des images.
La correction de distorsion géométrique est un traitement qu'il est intéressant de réaliser au moment de la prise de vue car il ne dépend que de l'objectif utilisé. Le script suivant effectue la correction de distorsion à partir des données de calibration de l'objectif enregistrées dans un fichier :
from pypylon import pylon
import cv2 as cv
import numpy as np
camera = pylon.InstantCamera(pylon.TlFactory.GetInstance().CreateFirstDevice())
camera.Open()
nodemap = camera.GetNodeMap()
width = nodemap.GetNode("Width")
height = nodemap.GetNode("Height")
print("width = %d"%width.GetValue())
print("height = %d"%height.GetValue())
w = 1280
h = 1024
width.SetValue(w)
height.SetValue(h)
offsetX = nodemap.GetNode("OffsetX")
offsetY = nodemap.GetNode("OffsetY")
offsetX.SetValue(0*16)
offsetY.SetValue(0*16)
autoexp = False
exptime = 9500 # en microsecondes 
exposureAuto = nodemap.GetNode("ExposureAuto")
if autoexp:
	exposureAuto.FromString("Once") # réglage automatique de l'exposition : une seule fois
else:
	exposureAuto.FromString("Off")
	exposureTime = nodemap.GetNode("ExposureTime")
	exposureTime.FromString("%d"%exptime)
balanceWhiteAuto = nodemap.GetNode("BalanceWhiteAuto")
balanceWhiteAuto.FromString("Once")
framerate = 100 # en Hz
nodemap.GetNode("AcquisitionFrameRateEnable").FromString("1")
nodemap.GetNode("AcquisitionFrameRate").FromString("%d"%framerate)
framerate = nodemap.GetNode("ResultingFrameRate").GetValue()
print("Framerate = %f"%framerate)
converter = pylon.ImageFormatConverter()
converter.OutputPixelFormat = pylon.PixelType_BGR8packed
converter.OutputBitAlignment = pylon.OutputBitAlignment_MsbAligned
#calibration objectif 8,5 mm
[fx,fy,cx,cy,k1,k2,k3,p1,p2] = np.loadtxt("basler-calibration-8,5mm.txt")
print(fx,fy,cx,cy,k1,k2,k3,p1,p2)
mtx = np.array([[fx,0,cx],[0,fy,cy],[0,0,1]])
dist = np.array([[k1,k2,k3,p1,p2]])
newcameramtx, roi = cv.getOptimalNewCameraMatrix(mtx, dist, (w,h), 1, (w,h))
print(roi)
x, y, w, h = roi # fenêtre finale
camera.MaxNumBuffer = 1000 # taille de la file (nombre de tampons maximal)
camera.StartGrabbing(pylon.GrabStrategy_OneByOne)
fourcc = cv.VideoWriter_fourcc(*'mpg1')
videoWriter = cv.VideoWriter('video.mpg',fourcc,30,(w,h))
f = 0
numFrames = 1000
while camera.IsGrabbing() and f < numFrames:
	timeout = 10000 # ms, temps d'attente maximal de l'image
	grabResult = camera.RetrieveResult(timeout, pylon.TimeoutHandling_ThrowException)
	if grabResult.GrabSucceeded():
		skip = grabResult.GetNumberOfSkippedImages()
		if skip!=0: print("skip : %d"%skip)
		image = converter.Convert(grabResult)
		img = image.GetArray() # image au format OpenCV (BGR), tableau np.ndarray
		dst = cv.undistort(img, mtx, dist, None, newcameramtx)
		dst = dst[y:y+h, x:x+w]
		videoWriter.write(dst)
		f += 1
	grabResult.Release()
print("%d frames"%f)
camera.StopGrabbing()
camera.Close()
cv.destroyAllWindows()
videoWriter.release()	
				L'image après traitement a une taille un peu plus petite que la taille initiale car la ROI permet d'enlever les bords, qui sont courbés après correction. Le temps d'exécution du script est beaucoup plus long que sans la correction car celle-ci est un traitement relativement long. Pour cet enregistrement dont la durée est 10 s, le script met 26 secondes pour faire l'enregistrement complet alors qu'il met 11 secondes en l'absence de correction.
Le déclenchement matériel est expliqué en détail dans Déclenchement d'une caméra Basler.
L'objectif est de déclencher le début d'acquisition de chaque image par un signal externe, généré par un microcontrôleur. La configuration se fait avec les paramètres suivants :
Le programme Arduino génère N impulsions de fréquence donnée. Le script suivant effectue l'enregistrement de N images avec déclenchement de chacune sur un front montant du signal.
from pypylon import pylon
import cv2 as cv
import numpy as np
camera = pylon.InstantCamera(pylon.TlFactory.GetInstance().CreateFirstDevice())
camera.Open()
nodemap = camera.GetNodeMap()
width = nodemap.GetNode("Width")
height = nodemap.GetNode("Height")
print("width = %d"%width.GetValue())
print("height = %d"%height.GetValue())
w = 1280
h = 1024
width.SetValue(w)
height.SetValue(h)
offsetX = nodemap.GetNode("OffsetX")
offsetY = nodemap.GetNode("OffsetY")
offsetX.SetValue(0*16)
offsetY.SetValue(0*16)
autoexp = False
exptime = 9500 # en microsecondes 
exposureAuto = nodemap.GetNode("ExposureAuto")
if autoexp:
	exposureAuto.FromString("Once") # réglage automatique de l'exposition : une seule fois
else:
	exposureAuto.FromString("Off")
	exposureTime = nodemap.GetNode("ExposureTime")
	exposureTime.FromString("%d"%exptime)
balanceWhiteAuto = nodemap.GetNode("BalanceWhiteAuto")
balanceWhiteAuto.FromString("Once")
nodemap.GetNode("AcquisitionFrameRateEnable").FromString("0")
nodemap.GetNode("TriggerSelector").FromString("FrameStart")
nodemap.GetNode("TriggerMode").FromString("On")
nodemap.GetNode("TriggerSource").FromString("Line1")
converter = pylon.ImageFormatConverter()
converter.OutputPixelFormat = pylon.PixelType_BGR8packed
converter.OutputBitAlignment = pylon.OutputBitAlignment_MsbAligned
numFrames = 1000 # 1000 impulsions doivent reçues sur Line 1	
camera.MaxNumBuffer = numFrames # taille de la file (nombre de tampons maximal)
camera.StartGrabbing(pylon.GrabStrategy_OneByOne)
fourcc = cv.VideoWriter_fourcc(*'mpg1')
videoWriter = cv.VideoWriter('video.mpg',fourcc,30,(w,h))
f = 0
while camera.IsGrabbing() and f < numFrames:
	timeout = 100000 # ms, temps d'attente maximal de l'image
	grabResult = camera.RetrieveResult(timeout, pylon.TimeoutHandling_ThrowException)
	if grabResult.GrabSucceeded():
		skip = grabResult.GetNumberOfSkippedImages()
		if skip!=0: print("skip : %d"%skip)
		image = converter.Convert(grabResult)
		img = image.GetArray() # image au format OpenCV (BGR), tableau np.ndarray
		# placer ici le traitement d'image 
		videoWriter.write(img)
		f += 1
	grabResult.Release()
print("%d frames"%f)
camera.StopGrabbing()
camera.Close()
cv.destroyAllWindows()
videoWriter.release()	
				Si, pour une raison ou une autre, le nombre d'impulsions reçues est inférieur au nombre d'images prévu (ici 1000), le délai d'attente dans la fonction timeout permet au programme de ne pas rester bloqué, mais il faut bien sûr que le début du signal de déclenchement se fasse avant ce délai. Si le nombre d'impulsions générées est supérieur au nombre d'images prévu, la boucle se termine lorsque ce nombre est atteint.
 Textes et figures sont mis à disposition sous  contrat Creative Commons.
Textes et figures sont mis à disposition sous  contrat Creative Commons.