こんにちは!!!クライアントエンジニアの小林です。
今回は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__
からにしておいてください。
サンプルコードは以下の処理を順番にしています。
- mssインスタンスの生成
- 撮影範囲が左上座標と右下座標を指定
- ScreenShotからndarray[uint8]に変換
- 撮影結果はBGRA配置なので、最初にスライスでAlphaを削って、次にフリップでRGB配置に転換
フリップ処理を省いた方が速いので、BGRで処理を組んでも良き - 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関数内で撮影処理をしてしまうと、撮影処理が原因で、tkinterのGUI処理に遅延が発生してしまうためです。
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という環境が天国なのでした。