☠ 2018-05-06 : cette page n'est plus vraiment d'actualité. L'article est en cours de réécriture, en attendant, reportez-vous au dépôt officiel.

Afin de décrire plus en détails le fonctionnement du module MSS, je vais présenter une manière de prendre une capture d'écran (ou des écrans) en passant par les ctypes.
Vite fait pour vous mettre dans le bain, ctypes est un module qui permet d'accéder aux fonctions et symboles d'une bibliothèque externe (la plupart du temps, codées en C ou C++). Du coup, on peut accéder aux fonctions du système hôte assez aisément. Cela va nous servir à imiter tout le processus effectué lorsque l'on appuie sur la touche Imp écr Syst (ou PrtScrn).

La bonne façon de jouer avec ctypes, surtout pour ne pas avoir de surprises suivant les versions du système d'exploitation et des architectures, est de déclarer le type des arguments des fonctions ainsi que le type qu'elles retournent. Cela se fait grâce aux attributs argtypes et restypes respectivement.


Processus de l'impression écran sous Windows

Par défaut, vous vous retrouverez avec une image qui contient tous les écrans dans une seule capture. Ce fonctionnement, nous devrons le mettre en place manuellement, sinon nous nous retrouverons avec une capture de l'écran principal seulement.

Il vous faudra appréhender quelques termes techniques dont :

  • GDI ou Graphics Device Interface est une interface de périphérique graphique. Elle permet l'affichage de tout ce qui est graphique à l'écran ou sur une imprimante.
  • DC ou Device Context est un contexte de périphérique (l'écran, l'imprimante ou un bitmap en mémoire).

Sans plus tarder, le processus pour capturer un seul écran :

  • récupérer un ID du DC principal, càd demander l'autorisation d'utiliser le DC principal (celui qui contient toutes les fenêtres et écrans)
  • récupérer un ID vers un DC de la partie cliente, càd demander l'autorisation d'utiliser une zone mémoire pour y copier les pixels de l'écran
  • créer un bitmap compatible (zone mémoire qui contiendra les données brutes de l'image finale)
  • en très raccourci : copier les données brutes du DC de la partie cliente vers le bitmap en mémoire
  • finalement, transférer le bitmap en mémoire vers un fichier physique : notre capture d'écran

Pour prendre une capture d'un autre écran, le processus est le même, il suffira d'adapter les coordonnées et dimensions lors du transfert des pixels. Idem pour prendre une capture de tous les écrans d'un coup.


Les mains dans le cambouis

Imports

Voici la liste des imports nécessaires ainsi que la définition des structures pour le bitmap :

from ctypes import byref, memset, pointer, sizeof, windll
from ctypes.wintypes import (
	c_void_p as LPRECT,
	c_void_p as LPVOID,
	create_string_buffer,
	Structure,
	BOOL,
	DOUBLE,
	DWORD,
	HANDLE,
	HBITMAP,
	HDC,
	HGDIOBJ,
	HWND,
	INT,
	LPARAM,
	LONG,
	POINTER,
	RECT,
	SHORT,
	UINT,
	WINFUNCTYPE,
	WORD
)

class BITMAPINFOHEADER(Structure):
	_fields_ = [
		('biSize', DWORD),
		('biWidth', LONG),
		('biHeight', LONG),
		('biPlanes', WORD),
		('biBitCount', WORD),
		('biCompression', DWORD),
		('biSizeImage', DWORD),
		('biXPelsPerMeter', LONG),
		('biYPelsPerMeter', LONG),
		('biClrUsed', DWORD),
		('biClrImportant', DWORD)
	]

class BITMAPINFO(Structure):
	_fields_ = [
		('bmiHeader', BITMAPINFOHEADER),
		('bmiColors', DWORD * 3)
	]

BITMAPINFOHEADER est une structure qui défini les entêtes du fichier bitmap, BITMAPINFO en est une autre qui contient une image complète (entêtes + données).


Initilisations

Par soucis de lisibilité et de maintenabilité, nous allons définir nos propres variables qui seront en fait des alias de fonctions.

SM_XVIRTUALSCREEN = 76  # Coordonnée gauche *
SM_YVIRTUALSCREEN = 77  # Coordonnée haute *
SM_CXVIRTUALSCREEN = 78  # Largeur en pixels *
SM_CYVIRTUALSCREEN = 79  # Hauteur en pixels *
SRCCOPY = 0xCC0020  # Code de copie pour la fonction BitBlt()
DIB_RGB_COLORS = 0

GetSystemMetrics = windll.user32.GetSystemMetrics
EnumDisplayMonitors = windll.user32.EnumDisplayMonitors
GetWindowDC = windll.user32.GetWindowDC
CreateCompatibleDC = windll.gdi32.CreateCompatibleDC
CreateCompatibleBitmap = windll.gdi32.CreateCompatibleBitmap
SelectObject = windll.gdi32.SelectObject
BitBlt = windll.gdi32.BitBlt
GetDIBits = windll.gdi32.GetDIBits
DeleteObject = windll.gdi32.DeleteObject

* Il s'agit des coordonnées et dimensions de l'écran virtuel, celui qui contient tous les écrans réunis.
Pour plus d'informations sur une fonction, reportez-vous à sa documentation sur le Windows Dev Center (MSDN).


Type d'arguments

Bon à savoir, argtypes ne peut être qu'une liste.

MONITORENUMPROC = WINFUNCTYPE(INT, DWORD, DWORD, POINTER(RECT), DOUBLE)
GetSystemMetrics.argtypes = [INT]
EnumDisplayMonitors.argtypes = [HDC, LPRECT, MONITORENUMPROC, LPARAM]
GetWindowDC.argtypes = [HWND]
CreateCompatibleDC.argtypes = [HDC]
CreateCompatibleBitmap.argtypes = [HDC, INT, INT]
SelectObject.argtypes = [HDC, HGDIOBJ]
BitBlt.argtypes = [HDC, INT, INT, INT, INT, HDC, INT, INT, DWORD]
DeleteObject.argtypes = [HGDIOBJ]
GetDIBits.argtypes = [HDC, HBITMAP, UINT, UINT, LPVOID,
						POINTER(BITMAPINFO), UINT]

MONITORENUMPROC() est une fonction de rappel (callback) pour l'énumération des écrans disponibles. Elle permet de récupérer les dimensions, coordonnées et ID de chaque écran ou de tous à la fois.


Type de fonction

Rien de bien difficile ici, juste des types, encore et toujours.

GetSystemMetrics.restypes = INT
EnumDisplayMonitors.restypes = BOOL
GetWindowDC.restypes = HDC
CreateCompatibleDC.restypes = HDC
CreateCompatibleBitmap.restypes = HBITMAP
SelectObject.restypes = HGDIOBJ
BitBlt.restypes =  BOOL
GetDIBits.restypes = INT
DeleteObject.restypes = BOOL

C'est parti !

Informations des écrans

Parce qu'il faut bien commencer quelque part, récupérons les dimensions et coordonnées des écrans disponibles :

def enum_display_monitors(oneshot=False):
    # Si oneshot est à True, alors on récupère les informations de tous
    # les écrans d'un coup.
    # Retourne une liste de dictionnaires contenant les informations
    # des écrans.

    def _callback(monitor, dc, rect, data):
        rct = rect.contents
        results.append({
            b'left'  : int(rct.left),
            b'top'   : int(rct.top),
            b'width' : int(rct.right - rct.left),
            b'height': int(rct.bottom -rct.top)
        })
        return 1

    results = []
    if oneshot:
        left = GetSystemMetrics(SM_XVIRTUALSCREEN)
        right = GetSystemMetrics(SM_CXVIRTUALSCREEN)
        top = GetSystemMetrics(SM_YVIRTUALSCREEN)
        bottom = GetSystemMetrics(SM_CYVIRTUALSCREEN)
        results.append({
            b'left'  : int(left),
            b'top'   : int(top),
            b'width' : int(right - left),
            b'height': int(bottom - top)
        })
    else:
        callback = MONITORENUMPROC(_callback)
        EnumDisplayMonitors(0, 0, callback, 0)
    return results

Exemples de retour :

# Un seul écran :
[{'width': 1024, 'top': 0, 'height': 768, 'left': 0}]

# Deux écrans :
[
	{'width': 1366, 'top': 0, 'height': 768, 'left': 0},
	{'width': 1280, 'top': 0, 'height': 1024, 'left': 1366}
]

# Deux écrans, oneshot=True :
[{'width': 2646, 'top': 0, 'height': 1024, 'left': 0}]

Récupération des pixels

C'est ici que sont traduites les étapes du § Processus de l'impression écran sous Windows.

def get_pixels(monitor):
	# Récupérer les pixels d'un écran.
	
	width, height = monitor['width'], monitor['height']
	left, top = monitor['left'], monitor['top']
	
	# Récupérer un ID du DC principal
	srcdc = GetWindowDC(0)
	# Récupérer un ID vers un DC de la partie cliente
	memdc = CreateCompatibleDC(srcdc)
	# Créer un bitmap compatible
	bmp = CreateCompatibleBitmap(srcdc, width, height)
	# Sélection du bitmap nouvellement créé
	SelectObject(memdc, bmp)
	# Copie des pixels du DC principal vers le DC de la partie cliente ;
	# c'est ici qu'on spécifie les coordonnées et la taille de la capture.
	BitBlt(memdc, 0, 0, width, height, srcdc, left, top, SRCCOPY)
	# Nouvelle image BMP, remplissage des informations
	bmi = BITMAPINFO()
	bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER)
	bmi.bmiHeader.biWidth = width
	bmi.bmiHeader.biHeight = height
	bmi.bmiHeader.biBitCount = 24
	bmi.bmiHeader.biPlanes = 1
	# Allocation d'un buffer pour le transfert des données
	buffer_len = height * ((width * 3 + 3) & -4)
	pixels = create_string_buffer(buffer_len)
	# Récupération des données brutes (pixels) du DC de la partie cliente
	# vers l'image BMP
	bits = GetDIBits(memdc, bmp, 0, height, byref(pixels),
							pointer(bmi), DIB_RGB_COLORS)
	
	# Un peu de ménage
	DeleteObject(srcdc)
	DeleteObject(memdc)
	DeleteObject(bmp)
	
	# Vérifions que tout s'est bien passé
	if bits != height or len(pixels.raw) != buffer_len:
		raise ValueError('MSSWindows: GetDIBits() failed.')

	return pixels.raw

/!\ Dans le code, à la ligne de définition de buffer_len, il y a un & qui traine, il faut le remplacer par &.

Spécialité de Microsoft oblige, le début de l'image est en bas à gauche et les pixels sont dans l'ordre BGR... Ne cherchez pas une logique là-dedans o_Ô.


Boucle générale

Parce que je n'ai pas encore terminé la classe MSSImage (qui permettra de se passer de module tierce pour la sauvegarde des images), nous utiliserons Pillow pour enregistrer l'image dans un fichier.

if __name__ == '__main__':
    # Utilisation de Pillow (ou PIL) pour enregistrer l'image.
    # MSSImage est prêt mais trop long à inclure ici.
    from PIL import Image, ImageFile

    def pil_save(filename, width, height):
        buffer_len = (width * 3 + 3) & -4
        img = Image.frombuffer('RGB', (width, height), pixels, 'raw',
            'RGB', buffer_len, 1)
        ImageFile.MAXBLOCK = width * height
        img.save(filename, quality=95, optimize=True, progressive=True)
        print('Fichier {0} créé.'.format(filename))

    # Une capture par écran
    i = 1
    for monitor in enum_display_monitors():
        pixels = get_pixels(monitor)
        filename = 'mss-capture-{0}.jpg'.format(i)
        pil_save(filename, monitor['width'], monitor['height'])
        i += 1

    # Capture complète
    monitor = enum_display_monitors(oneshot=True)[0]
    pixels = get_pixels(monitor)
    filename = 'mss-capture-complet.jpg'
    pil_save(filename, monitor['width'], monitor['height'])

/!\ Dans le code, à la ligne de définition de buffer_len, il y a un & qui traine, il faut le remplacer par &.

Dans la fonction Image.frombuffer(), c'est le dernier argument qui permet de spécifier que le début de l'image est en bas à gauche (-1).

Voici le script complet.


Sources :