Voici le second article qui décrit en détail le fonctionnement du module Python MSS :

  • Partie 1 : Windows
  • Partie 2 : GNU/Linux (vous y êtes ^^)
  • Partie 3 : macOS

Nous utiliserons intensivement le module ctypes.


Processus de l'impression écran sous GNU/Linux

Voici le processus pour capturer une zone du serveur X  :

  • connexion au serveur X
  • récupérer l'ID vers la fenêtre racine
  • récupérer une image de la zone désirée
  • copier les pixels de le l'image
  • enregistrer les pixels dans une image physique

Imports

La liste des imports nécessaires, rien d'extravagant :

import ctypes
import ctypes.util
from typing import Dict, List

# Création d'un type qui contiendra les informations d'un écran :
Monitor = Dict[str, int]

Structures

Une structure est un type utilisé par ctypes pour transférer des données entre Python et C. Il est important de les définir en amont de leur utilisation pour avoir accès aux données sous leur bon format.

class Display(ctypes.Structure):
    """
    Structure qui sert de connexion au serveur X et qui contient toutes
    les informations à propos de ce serveur X.
    """


class XWindowAttributes(ctypes.Structure):
    """ Les attributs d'une fenêtre spécifique. """

    _fields_ = [('x', ctypes.c_int32),
                ('y', ctypes.c_int32),
                ('width', ctypes.c_int32),
                ('height', ctypes.c_int32),
                ('border_width', ctypes.c_int32),
                ('depth', ctypes.c_int32),
                ('visual', ctypes.c_ulong),
                ('root', ctypes.c_ulong),
                ('class', ctypes.c_int32),
                ('bit_gravity', ctypes.c_int32),
                ('win_gravity', ctypes.c_int32),
                ('backing_store', ctypes.c_int32),
                ('backing_planes', ctypes.c_ulong),
                ('backing_pixel', ctypes.c_ulong),
                ('save_under', ctypes.c_int32),
                ('colourmap', ctypes.c_ulong),
                ('mapinstalled', ctypes.c_uint32),
                ('map_state', ctypes.c_uint32),
                ('all_event_masks', ctypes.c_ulong),
                ('your_event_mask', ctypes.c_ulong),
                ('do_not_propagate_mask', ctypes.c_ulong),
                ('override_redirect', ctypes.c_int32),
                ('screen', ctypes.c_ulong)]


class XImage(ctypes.Structure):
    """
    Données d'une image du serveur X telle qu'elle est représentée
    en mémoire.
    https://tronche.com/gui/x/xlib/graphics/images.html
    """

    _fields_ = [('width', ctypes.c_int),
                ('height', ctypes.c_int),
                ('xoffset', ctypes.c_int),
                ('format', ctypes.c_int),
                ('data', ctypes.c_void_p),
                ('byte_order', ctypes.c_int),
                ('bitmap_unit', ctypes.c_int),
                ('bitmap_bit_order', ctypes.c_int),
                ('bitmap_pad', ctypes.c_int),
                ('depth', ctypes.c_int),
                ('bytes_per_line', ctypes.c_int),
                ('bits_per_pixel', ctypes.c_int),
                ('red_mask', ctypes.c_ulong),
                ('green_mask', ctypes.c_ulong),
                ('blue_mask', ctypes.c_ulong)]


class XRRModeInfo(ctypes.Structure):
    """
    Aucune idée de ce qu'il s'agit, mais c'est nécessaire.
    Voilà, voilà.  Je suis preneur de toute information à ce sujet.
    """


class XRRScreenResources(ctypes.Structure):
    """ Structure qui contient une liste moniteurs disponibles. """

    _fields_ = [('timestamp', ctypes.c_ulong),
                ('configTimestamp', ctypes.c_ulong),
                ('ncrtc', ctypes.c_int),
                ('crtcs', ctypes.POINTER(ctypes.c_long)),
                ('noutput', ctypes.c_int),
                ('outputs', ctypes.POINTER(ctypes.c_long)),
                ('nmode', ctypes.c_int),
                ('modes', ctypes.POINTER(XRRModeInfo))]


class XRRCrtcInfo(ctypes.Structure):
    """ Structure qui contient les informations sur un moniteur. """

    _fields_ = [('timestamp', ctypes.c_ulong),
                ('x', ctypes.c_int),
                ('y', ctypes.c_int),
                ('width', ctypes.c_int),
                ('height', ctypes.c_int),
                ('mode', ctypes.c_long),
                ('rotation', ctypes.c_int),
                ('noutput', ctypes.c_int),
                ('outputs', ctypes.POINTER(ctypes.c_long)),
                ('rotations', ctypes.c_ushort),
                ('npossible', ctypes.c_int),
                ('possible', ctypes.POINTER(ctypes.c_long))]

Initilisations

Nous allons définir une classe MSS où chaque fonction jouera un rôle bien précis. Tout simplement :

class MSS:
    """
    Implémentation d'une capture d'écran sous GNU/Linux.
    Utilisations intensives de la Xlib et son extension Xrandr.
    """

    def __init__(self, display: bytes=b':0') -> None:
        """
        Initialisations.
        `display` est le contenu de la variable d'environnement DISPLAY.
        """
        pass

    def _set_argtypes(self) -> None:
        """
        Définition des arguments des fonctions utilisées
        via ctypes.
        `argtypes` est toujours une liste de types ctypes.
        """
        pass
    
    def _set_restypes(self) -> None:
        """ 
        Définition du type retourné par les fonctions utilisées
        via ctypes.
        """
        pass
    
    @property
    def monitors(self) -> List[Monitor]:
        """ Récupérer les informations sur les écrans. """
        pass
    
    def grab(self, monitor: Monitor) -> bytes:
        """ Rapatriement des pixels d'une zone de l'écran. """
        pass

Ensuite, chargeons les bibliothèques qui nous intéressent via ctypes :

def __init__(self, ...):
    # Xlib
    x11 = ctypes.util.find_library('X11')
    self.xlib = ctypes.cdll.LoadLibrary(x11)

    # Xrandr
    xrandr = ctypes.util.find_library('Xrandr')
    self.xrandr = ctypes.cdll.LoadLibrary(xrandr)

    # Appelons ces 2 méthodes maintenant, elles seront remplies
    # au fur et à mesure des besoins.
    self._set_argtypes()
    self._set_restypes()

Connexion au serveur X

Pour plus d'informations sur une fonction de la Xlib, reportez-vous à sa documentation en ligne qui est une vraie mine d'or.

XOpenDisplay permet d'ouvrir une connexion vers le serveur X. Ensuite, nous récupérons l'ID de la fenêtre racine, grâce à laquelle nous pourrons copier des pixels plus tard :

def __init__(self, ...):
    # ...

    # Connexion au serveur X
    self.display = self.xlib.XOpenDisplay(display)

    # Récupération de l'ID de la fenêtre racine
    self.root = self.xlib.XDefaultRootWindow(
        self.display, self.xlib.XDefaultScreen(self.display))

    # Fix pour XRRGetScreenResources et XGetImage:
    #     expected LP_Display instance instead of LP_XWindowAttributes
    self.drawable = ctypes.cast(self.root, ctypes.POINTER(Display))

Nous venons d'introduire 3 fonctions de la Xlib. Avant de pouvoir les utiliser, il faut informer Python de leurs signatures et de ce qu'elles vont renvoyer comme données :

def _set_argtypes(self):
    self.xlib.XOpenDisplay.argtypes = [ctypes.c_char_p]
    self.xlib.XDefaultScreen.argtypes = [ctypes.POINTER(Display)]
    self.xlib.XDefaultRootWindow.argtypes = [
        ctypes.POINTER(Display),
        ctypes.c_int]

def _set_restypes(self):
    self.xlib.XOpenDisplay.restype = ctypes.POINTER(Display)
    self.xlib.XDefaultScreen.restype = ctypes.c_int
    self.xlib.XDefaultRootWindow.restype = \
        ctypes.POINTER(XWindowAttributes)

Informations sur les écrans

Récupérons les dimensions et coordonnées des écrans disponibles.
Il faut bien distinguer 2 cas : la zone virtuelle contenant tous les moniteurs activés et chaque moniteur individuellement.

@property
def monitors(self) -> List[Monitor]:
    monitors_ = []

    # Tous les moniteurs
    gwa = XWindowAttributes()
    self.xlib.XGetWindowAttributes(self.display,
                                   self.root,
                                   ctypes.byref(gwa))
    monitors_.append({
        'left': int(gwa.x),
        'top': int(gwa.y),
        'width': int(gwa.width),
        'height': int(gwa.height),
    })

    # Chaque moniteur individuellement
    mon = self.xrandr.XRRGetScreenResources(self.display,
                                            self.drawable)
    for idx in range(mon.contents.ncrtc):
        crtc = self.xrandr.XRRGetCrtcInfo(self.display, mon,
                                          mon.contents.crtcs[idx])
        if crtc.contents.noutput == 0:
            self.xrandr.XRRFreeCrtcInfo(crtc)
            continue

        monitors_.append({
            'left': int(crtc.contents.x),
            'top': int(crtc.contents.y),
            'width': int(crtc.contents.width),
            'height': int(crtc.contents.height),
        })
        # Free
        self.xrandr.XRRFreeCrtcInfo(crtc)

    # Libération des ressources
    self.xrandr.XRRFreeScreenResources(mon)

    return monitors_

monitors contiendra donc :

  • index 0 : la zone virtuelle couvrant tous les moniteurs actifs
  • index 1 : les informations de l'écran n° 1
  • index N : les informations de l'écran n° N

Hop, ajoutons aussi les nouvelles fonctions de la Xlib et Xrandr :

def _set_argtypes(self):
    # ...
    self.xlib.XGetWindowAttributes.argtypes = [
        ctypes.POINTER(Display),
        ctypes.POINTER(XWindowAttributes),
        ctypes.POINTER(XWindowAttributes)]
    self.xrandr.XRRGetScreenResources.argtypes = [
        ctypes.POINTER(Display),
        ctypes.POINTER(Display)]
    self.xrandr.XRRGetCrtcInfo.argtypes = [
        ctypes.POINTER(Display),
        ctypes.POINTER(XRRScreenResources),
        ctypes.c_long]
    self.xrandr.XRRFreeScreenResources.argtypes = [
        ctypes.POINTER(XRRScreenResources)]
    self.xrandr.XRRFreeCrtcInfo.argtypes = [ctypes.POINTER(XRRCrtcInfo)]

def _set_restypes(self):
    # ...
    self.xlib.XGetWindowAttributes.restype = ctypes.c_int
    self.xrandr.XRRGetScreenResources.restype = \
        ctypes.POINTER(XRRScreenResources)
    self.xrandr.XRRGetCrtcInfo.restype = ctypes.POINTER(XRRCrtcInfo)
    self.xrandr.XRRFreeScreenResources.restype = ctypes.c_void_p
    self.xrandr.XRRFreeCrtcInfo.restype = ctypes.c_void_p

Exemples de retour :

>>> mss = MSS()
>>> mss.monitors
# Un seul écran :
[{'height': 1080, 'left': 0, 'top': 0, 'width': 1920},
 {'height': 1080, 'left': 0, 'top': 0, 'width': 1920}]

# Deux écrans :
[{'height': 1920, 'left': 0, 'top': 0, 'width': 2360},
 {'height': 1080, 'left': 0, 'top': 0, 'width': 1920},
 {'height': 1080, 'left': 1280, 'top': 0, 'width': 1920}]

Récupération des pixels

Et voilà le cœur du combat ! Le point très positif c'est que la fonction fonctionne avec des zones : il suffit d'une liste de coordonnées et la Xlib fera tout le reste :

def grab(self, monitor: Monitor) -> bytes:
    # Constantes
    PLAINMASK = 0x00ffffff
    ZPIXMAP = 2

    ximage = self.xlib.XGetImage(
        self.display,
        self.drawable,
        monitor['left'],
        monitor['top'],
        monitor['width'],
        monitor['height'],
        PLAINMASK,
        ZPIXMAP)

    # Les pixels sont sous la forme BGRA
    data = ctypes.cast(ximage.contents.data, ctypes.POINTER(
        ctypes.c_ubyte * monitor['height'] * monitor['width'] * 4))
    data = bytes(data.contents)

    # Libération des ressources
    self.xlib.XDestroyImage(ximage)

    return data

Efnin, la dernière étape, cruciale : informer Python des nouvelles fonctions introduites.

def _set_argtypes(self):
    # ...
    self.xlib.XGetImage.argtypes = [
        ctypes.POINTER(Display),
        ctypes.POINTER(Display),
        ctypes.c_int,
        ctypes.c_int,
        ctypes.c_uint,
        ctypes.c_uint,
        ctypes.c_ulong,
        ctypes.c_int]
    self.xlib.XDestroyImage.argtypes = [ctypes.POINTER(XImage)]

def _set_restypes(self):
    # ...
    self.xlib.XGetImage.restype = ctypes.POINTER(XImage)
    self.xlib.XDestroyImage.restype = ctypes.c_void_p

Et voilà !


Quelques retouches

Pour nous assurer un bon fonctionnement et, surtout, éviter les fuites de mémoire (memory leaks en anglais), nous devons ajouter un peu de sucre (et parce que Python, c'est la classe aussi) :

class MSS:
    
    def __enter__(self) -> object:
        """ Context manager : `with MSS() as mss:`. """
        return self

    def __exit__(self, *_: str) -> None:
        """ Context manager : `with MSS() as mss:`. """

        # Assurons-nous de bien fermer la connexion en partant
        if hasattr(self, 'display'):
            self.xlib.XCloseDisplay(self.display)

    def _set_argtypes(self):
        # ...
        self.xlib.XCloseDisplay.argtypes = [ctypes.POINTER(Display)]

    def _set_restypes(self):
        # ...
        self.xlib.XCloseDisplay.restype = ctypes.c_void_p

    # ...

Automatisation

En l'état, ça n'est pas utilisable. Voici un bout de code qui prend une capture d'écran de chaque écran et l'enregistre dans un fichier PNG grâce à Pillow :

def main() -> None:
    """ Prendre une capture d'écran par moniteur. """

    from PIL import Image

    with MSS() as mss:
        for idx, monitor in enumerate(mss.monitors[1:], 1):
            pixels = mss.grab(monitor)
            size = monitor['width'], monitor['height']
            im = Image.frombytes('RGB', size, pixels, 'raw', 'BGRX')
            im.save(f"Capture de l'écran n°{idx}.png")


if __name__ == '__main__':
    main()

L'entièreté de l'article n'était qu'une explication pas-à-pas de ce fichier faisant parti du module MSS : linux.py.
Mais si vous souhaitez jouer avec l'exact script, le voici au complet : mss-linux.py.


Sources diverses


Historique

  • 2018-06-02 : Restructuration de l'article, utilisation de Python 3.6.5 pour le code présenté et suppression de la capture d'écran.
  • 2013-10-21 : Support de Python 3.
  • 2013-08-17 : Correction de la segfault.
  • 2013-08-16 : Correction de la détermination des coordonnées et dimensions (merci à Oros).