SPARKCREATIVE Tech Blog

https://www.spark-creative.jp/

Python デスクトップキャプチャ

こんにちは!!!クライアントエンジニアの小林です。

今回はmssライブラリを利用したデスクトップキャプチャを紹介します。

作業環境

windows 10
visual studio code
python 3.9.12

概要

pythonでデスクトップキャプチャというと、大体の方はそれに至るまでにopencvやpillowと触れあっているはずなので、そのあたりから探すかと思います。筆者も当然その道を歩んできたので、最初はpillowでキャプチャしていました。

ただこの子、ランタイム利用にとても不向きです。筆者の利用例を挙げると、前フレームと現フレームの撮影差分から、ゲームの画面遷移を検出してもらう必要がありました。このランタイム利用に不向きな理由はシンプルです。撮影処理に要する時間が長いのです。

大人しくWindowsAPI使うために雑にWPF化、Cythonから引っ張ってくるか、と考えていた際に出会ったのが、このmssライブラリです。

mssさんはとても優秀で、毎フレーム撮影処理を叩いても、割と爆速です。

ということを、当時組み込んだエディタコードを眺めていた際に思い出したので、軽くメモしてきます。

インストール方法

pip install mss

キャプチャ方法

ありがたいことにmssライブラリはドキュメントが整備されており、大体の内容は掲載されています。

エントリーポイントは、lock使われているので無難に__main__からにしておいてください。

サンプルコードは以下の処理を順番にしています。

  1. mssインスタンスの生成
  2. 撮影範囲が左上座標と右下座標を指定
  3. ScreenShotからndarray[uint8]に変換
  4. 撮影結果はBGRA配置なので、最初にスライスでAlphaを削って、次にフリップでRGB配置に転換
    フリップ処理を省いた方が速いので、BGRで処理を組んでも良き
  5. withのexit処理は、mss-7.0.1時点では何も実装されないが、アップデートで変わるかもしれないので、一応呼び出した方が安全


これだけのコードで爆速キャプチャが出来てしまいます。とっても簡単ですね。しかも、内部でlockかけてくれているので、気軽にマルチスレッドやプロセス化が可能というのも、嬉しい点です。

import mss
import numpy as np


if __name__ == "__main__":
    with mss.mss() as sct:
        # 撮影範囲
        left, top = 0, 0
        right, bottom = 128, 128

        # 撮影
        image = sct.grab((left, top, right, bottom))

        # ScreenShot to numpy
        image = np.array(image, dtype=np.uint8)

        # BGRA to RGB
        image = np.flip(image[:, :, :3], 2)

        # withで限定しない場合は、使用完了時に sct.exit() を呼んだ方が、ライブラリ更新時の事故防止に繋がる

特定のウィンドウをキャプチャ

mssさんが出来ることは撮影です。
どの範囲を撮影するかは、こちらで指定してあげる必要があります。

ここからはwin32apiを利用して、ウィンドウの一覧を取得、そしてそのウィンドウの座標を取得までやっていきます。

インストール

pip install pywin32

win32apiは割と環境設定によってはインストール手順が複雑になってしまうらしいので、インストールやインポートが上手くできない場合は、エラー文をよく読んで頑張ってください。

pythonで環境エラー踏むのはよくあることなので、たぶん皆さん慣れているでしょう。

アクティブなウィンドウ名一覧取得

数か月前だか数年前に書いたコードでなんのコメントも無いので分からないですが、これで取得できます。

エディタって実装が上手く行き過ぎるとメンテナンスフリーになって、一部機能がブラックボックス化しちゃうの、困りものですね。

最近は未来の自分のために事細かくコメントを書くように意識しているのですが、この当時はそうではなかったようです。

GW_OWNER = 4


def enum_window_callback(hwnd:int, lparam:int, window_titles:list[str]):
    if win32gui.IsWindowEnabled(hwnd) == 0:
        return

    if win32gui.IsWindowVisible(hwnd) == 0:
        return

    if (window_text:=win32gui.GetWindowText(hwnd)) == "":
        return

    if (owner:=win32gui.GetWindow(hwnd, GW_OWNER)) != 0:
        return

    if (class_name:=win32gui.GetClassName(hwnd)) in ["CabinetWClass"]:
        return

    if window_text not in window_titles:
        window_titles.append(window_text)


def get_window_titles() -> list[str]:
    window_titles:list[str] = []
    win32gui.EnumWindows(partial(enum_window_callback, window_titles=window_titles), 0)
    window_titles.sort()
    return window_titles

ウィンドウ名から位置を取得

先ほどなぜウィンドウ名を取得したのかというと、win32gui.FindWindow関数で指定したウィンドウ名のhwndを取得するためです。それを元にウィンドウ位置を取ってきます。

def get_capture_image(window_title:str, sct:MSSBase) -> Union[np.ndarray, None]:
    if window_title == "":
        return None

    if (hwnd:=win32gui.FindWindow(None, window_title)) == -1:
        return None

    rect = ctypes.wintypes.RECT()
    ctypes.windll.dwmapi.DwmGetWindowAttribute(
        ctypes.wintypes.HWND(hwnd),
        ctypes.wintypes.DWORD(DWMWA_EXTENDED_FRAME_BOUNDS),
        ctypes.byref(rect),
        ctypes.sizeof(rect),
    )

    bbox = rect.left, rect.top, rect.right, rect.bottom

    image = sct.grab(bbox)
    image = np.array(image, dtype=np.uint8)
    image = np.flip(image[:, :, :3], 2)

    return image

tkinterとttkbootstrapで最小GUI

ちょっと気分が乗ってきたので、tkinterで簡単にGUIを作ってみます。

ttkbootstrap

包み隠さずいうと、tkinterはとってもダサいのです。
ttkbootstrapを利用するとちょっとオシャレになります。

画像表示キャンバス

tkinterで画像を表示する際は変換処理をしないといけないので、書き始めてから面倒だなと思いましたが、過去に作成したライブラリがあったので、それを丸っとコピペしました。我ながらに汎用性◎な実装で過去の我に感激しました。唯一の欠点は有益なコメントを書いていないことですね。

import tkinter as tk
import ttkbootstrap as ttk
import win32com
import win32com.client
import ctypes
import ctypes.wintypes
import numpy as np
from PIL import Image, ImageTk


__all__ = [
    "ImageLabel",
    "ImageCanvas",
    "ScrollImageCanvas",
]


class ImageLabel(tk.Label):
    def __init__(
        self,
        master:tk.Misc=None,
        width:int=1920//2,
        height:int=1080//2,
        borderwidth:int=0,
    ):
        self.image = np.full((height, width, 3), (255, 0, 0), np.uint8)
        self.image = self.create_photo_image(self.image)

        self.width = width
        self.height = height

        super().__init__(master, borderwidth=borderwidth, image=self.image, width=width, height=height)

    def create_photo_image(self, image:np.ndarray) -> ImageTk.PhotoImage:
        image = Image.fromarray(image)
        return ImageTk.PhotoImage(image)

    def set_image(self, image:np.ndarray) -> tuple[int, int]:
        """画像をセット

        Args:
            image (np.ndarray): _description_

        Returns:
            tuple[int, int]: image width size, image height size
        """
        # 画像の作成と更新
        self.image = self.create_photo_image(image)
        self.configure(image=self.image)
        # 作成した画像の縦横サイズを返す
        return self.image.width(), self.image.height()

    def yview(self, *args) -> tuple[float, float]:
        return 0.0, 0.0

    def xview(self, *args) -> tuple[float, float]:
        return 0.0, 0.0


class ImageCanvas(tk.Canvas):
    def __init__(
        self,
        master:tk.Misc=None,
        width:int=1920//2,
        height:int=1080//2,
        borderwidth:int=0,
    ):
        super().__init__(master, width=width, height=height, borderwidth=borderwidth)

        # 初期画像の作成
        self.image = np.full((height, width, 3), (255, 0, 0), np.uint8)
        self.image = self.create_photo_image(self.image)

        self.item_id = self.create_image(0, 0, anchor=tk.NW, image=self.image)

    def create_photo_image(self, image:np.ndarray) -> ImageTk.PhotoImage:
        image = Image.fromarray(image)
        return ImageTk.PhotoImage(image)

    def set_image(self, image:np.ndarray) -> tuple[int, int]:
        """画像をセット

        Args:
            image (np.ndarray): _description_

        Returns:
            tuple[int, int]: image width size, image height size
        """
        # 画像の作成と更新
        self.image = self.create_photo_image(image)
        self.itemconfig(self.item_id, image=self.image)


class ScrollImageCanvas:
    def __init__(
        self,
        master:tk.Misc=None,
        width:int=1920//2,
        height:int=1080//2,
        borderwidth:int=0,
    ):
        self.image_canvas = ImageCanvas(master, width, height, borderwidth)
        self.image_canvas_yview = ttk.Scrollbar(master, orient=tk.VERTICAL, command=self.image_canvas.yview)
        self.image_canvas_xview = ttk.Scrollbar(master, orient=tk.HORIZONTAL, command=self.image_canvas.xview)
        self.image_canvas.configure(xscrollcommand=self.image_canvas_xview.set, yscrollcommand=self.image_canvas_yview.set)

    def grid(self, column:int, row:int, padx:tuple[int, int], pady:tuple[int, int]) -> None:
        self.image_canvas.grid(column=column, row=row, sticky=tk.EW, padx=(padx[0], 0), pady=(pady[0], 0))
        self.image_canvas_yview.grid(column=column+1, row=row, rowspan=2, sticky=tk.NS, padx=(0, padx[1]), pady=pady)
        self.image_canvas_xview.grid(column=column, row=row+1, sticky=tk.EW, padx=(padx[0], 0), pady=(0, pady[1]))

    def set_image(self, image:np.ndarray) -> None:
        self.image_canvas.set_image(image)
        self.image_canvas.configure(scrollregion=(0, 0, image.shape[1], image.shape[0]))

アプリケーション

コマンドやバインド系は関数作るのが面倒だったのでlambdaで突っ込んでいます。

撮影処理を同一スレッドのupdate関数で実行していますが、実際に利用できるモノを組む場合は、別スレッドかプロセスで撮影して、その結果をQueueに突っ込んで、updateで取得する形がベストです。

update関数内で撮影処理をしてしまうと、撮影処理が原因で、tkinterGUI処理に遅延が発生してしまうためです。

import tkinter as tk
import ttkbootstrap as ttk
import win32gui
import win32com
import win32com.client
import ctypes
import ctypes.wintypes
from functools import partial
import numpy as np
import mss
from mss.base import MSSBase
from typing import Union

from source.image_label import ScrollImageCanvas


GW_OWNER = 4
DWMWA_EXTENDED_FRAME_BOUNDS = 9


def enum_window_callback(hwnd:int, lparam:int, window_titles:list[str]):
    if win32gui.IsWindowEnabled(hwnd) == 0:
        return

    if win32gui.IsWindowVisible(hwnd) == 0:
        return

    if (window_text:=win32gui.GetWindowText(hwnd)) == "":
        return

    if (owner:=win32gui.GetWindow(hwnd, GW_OWNER)) != 0:
        return

    if (class_name:=win32gui.GetClassName(hwnd)) in ["CabinetWClass"]:
        return

    if window_text not in window_titles:
        window_titles.append(window_text)


def get_window_titles() -> list[str]:
    window_titles:list[str] = []
    win32gui.EnumWindows(partial(enum_window_callback, window_titles=window_titles), 0)
    window_titles.sort()
    return window_titles


def get_capture_image(window_title:str, sct:MSSBase) -> Union[np.ndarray, None]:
    if window_title == "":
        return None

    if (hwnd:=win32gui.FindWindow(None, window_title)) == -1:
        return None

    rect = ctypes.wintypes.RECT()
    ctypes.windll.dwmapi.DwmGetWindowAttribute(
        ctypes.wintypes.HWND(hwnd),
        ctypes.wintypes.DWORD(DWMWA_EXTENDED_FRAME_BOUNDS),
        ctypes.byref(rect),
        ctypes.sizeof(rect),
    )

    bbox = rect.left, rect.top, rect.right, rect.bottom

    image = sct.grab(bbox)
    image = np.array(image, dtype=np.uint8)
    image = np.flip(image[:, :, :3], 2)

    return image


class TestWindow(tk.Tk):
    def __init__(self) -> None:
        super().__init__()

        self.title("TestWindow")
        self.geometry("980x600")
        self.resizable(width=False, height=False)

        self.sct = mss.mss()

        self.columnconfigure(0, weight=1)
        self.rowconfigure(0, weight=1)

        # ウィンドウタイトルの選択
        self.window_title_selector = ttk.Combobox(
            self,
            state="readonly",
            justify=tk.CENTER,
            postcommand=lambda:self.window_title_selector.configure(values=get_window_titles()),
        )
        self.window_title_selector.bind(
            "<<ComboboxSelected>>",
            lambda event: self.window_title_selector.selection_clear(),
        )
        self.window_title_selector.grid(column=0, row=0, sticky=tk.EW, padx=(10, 10), pady=(10, 10), columnspan=2)

        # スクロールバー付きのキャンバス
        self.image_view = ScrollImageCanvas(self, 960, 500)
        self.image_view.grid(column=0, row=1, padx=(10, 10), pady=(10, 10))

        # start update.
        self.after(1, self.update)

    def destroy(self) -> None:
        super().destroy()
        self.sct.close()

    @property
    def window_title(self) -> str:
        return self.window_title_selector.get()

    def update(self):
        if (window_title:=self.window_title) != "":
            if (image:=get_capture_image(window_title, self.sct)) is not None:
                self.image_view.set_image(image)

        self.after(1, self.update)


if __name__ == "__main__":
    app = TestWindow()
    app.mainloop()

動作確認


おわり!!!

お疲れさまでした!!!

そもそも論、実行速度的にpythonでエディタを作るべきじゃない気もしますが、本実装でもないのに、色々と手続きを踏まなきゃぶっ壊れるC系言語で書くのは億劫なのです。

とっても面倒くさがりな筆者には、Python × mssという環境が天国なのでした。

参考サイト