Pythonで任意のウィンドウをレイヤー順に依存せずにWinAPIでキャプチャしてみた

Python

水ノ茉の宣伝

準備中...
ゲームを作る予定なの
水ノ茉こおり

始まり

セレオブ発売まで残り1か月を切りましたね。

最近の筆者さんは仕事終わりと休日には叡智作品をバックグラウンドで流しつつ、相も変わらずコードを書き続けております。

元々の作業スタイルが3画面のうち真ん中と左にVSとVSCode、右にYouTubeとその他だったので、YouTubeの部分が叡智作品に変わった感じです。

大抵の作品は標準でテキスト読み上げ機能が内蔵されているので、最早本当に趣味の領域になってしまいましたが、自分専用の読み上げツール、結構いい働きをしてくれています。

当然ながら叡智作品にのみフォーカスしているためOCR精度はほぼ100%、類似文字推定もLINEさんが公開しているDistilBERTをベースにFineTuningしているので結構な水準と、割と満足なモノが出来ました。

基本的には満足なのですが、使い始めてからちょっと不便だなと思う点が出てきました。

それはキャプチャ結果が画面上のレイヤーに依存する点です。

ウィンドウキャプチャにはMSSライブラリを使用しているのですが、この子、任意のウィンドウを撮ることは出来ません。
そのため、ウィンドウ全体をキャプチャした後に、対象のウィンドウ領域を切り抜く必要があります。

Welcome to Python MSS’s documentation!

mssさんの利点である爆速キャプチャは、大変助かっております。

ただ、筆者の作業スタイルだと叡智作品を置いているモニターにはGoogle先生やRenderDocちゃんだったり、色々なウィンドウが一時的に登場するのです。

そのたびにキャプチャ阻害されるのは、ちょっと不便だなと思い始めたので、画面上のレイヤーに依存せずに、ウィンドウハンドルから撮ってきて欲しいなと、思い始めた訳なのです。

自作ツールの良いところは、自分の理想通りにカスタマイズできるところです。

そんなわけで実装してきましょう。

MSSでキャプチャする方法(レイヤー順番に依存する撮り方)

get_window_bboxforeground_windowは、ReinLib(汎用ライブラリ)から取ってきてください。
ウィンドウハンドルをリスト表示で取ってくる実装もライブラリに含まれています。

https://github.com/kafues511/ReinLib/blob/d2427fde0a9122253a442cd492635adfcc8f841f/utility/rein_win32.py

is_foregroundはキャプチャするウィンドウを最前面に移動させるフラグです。
先に説明した撮影結果がレイヤー順に依存する関係で実装している機能です。

    def __init__(self) -> None:
        self.sct = mss.mss()

    def get_scene_color(self, hwnd:int, is_foreground:bool = True) -> npt.NDArray[np.uint8]:
        if is_foreground:
            foreground_window(hwnd, wait_time=0.0)  # キャプチャウィンドウを最前面に移動

        # 全画面キャプチャのため切り抜き範囲としてウィンドウの領域を取得
        window_bbox = BoundingBox(*get_window_bbox(hwnd))

        # ウィンドウ領域が小さすぎる場合は不正な可能性が高いので終わり
        if window_bbox.area < 640 * 320:
            return None

        # 全画面キャプチャの後にウィンドウ領域で切り抜き
        image = self.sct.grab(tuple(window_bbox))
        image = np.array(image, dtype=np.uint8)
        image = np.flip(image[:, :, :3], 2)  # BGRA2RGB

        if is_foreground and (hwnd:=win32gui.FindWindow(None, self.window_title())) != 0:
            foreground_window(hwnd, wait_time=0.0, after_dispatch="ESC")  # ウィンドウを最前面に移動

        return image

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

ウィンドウハンドルからキャプチャする方法(レイヤー順番に依存しない撮り方)

先駆者がいらしたので、丸っとコピーします。

Fast Window Capture - LearnCodeByGaming.com
Learn how to code and start your career in software development.

何度見たことかWinAPIさん。

学生時代は今時はゲームエンジンなんだから別に触らなくてもいいでしょと思っていましたが、現実は業務内外問わずに、バチクソ役立っているので、本当にムカつきます。

低レベルないし低レイヤーを履修しておくと結構な広範囲で役に立っちゃうんですよね。

触り始めは本当にバカクソつまらないけど、根本部分を抑えておくことで、出来ること、出来ないことが明確になるので、マジで強いです。

    def get_scene_color2(self, hwnd:int, is_foreground:bool = True) -> npt.NDArray[np.uint8]:
        window_bbox = BoundingBox(*get_window_bbox(hwnd))

        # get the window image data
        wDC = win32gui.GetWindowDC(hwnd)
        dcObj = win32ui.CreateDCFromHandle(wDC)
        cDC = dcObj.CreateCompatibleDC()
        dataBitMap = win32ui.CreateBitmap()
        dataBitMap.CreateCompatibleBitmap(dcObj, window_bbox.width, window_bbox.height)
        cDC.SelectObject(dataBitMap)
        cDC.BitBlt((0, 0), (window_bbox.width, window_bbox.height), dcObj, (0, 0), win32con.SRCCOPY)

        # convert the raw data into a format opencv can read
        signedIntsArray = dataBitMap.GetBitmapBits(True)
        image = np.frombuffer(signedIntsArray, dtype=np.uint8).reshape((window_bbox.height, window_bbox.width, 4))

        # free resources
        dcObj.DeleteDC()
        cDC.DeleteDC()
        win32gui.ReleaseDC(hwnd, wDC)
        win32gui.DeleteObject(dataBitMap.GetHandle())

        # drop the alpha channel
        image = np.flip(image[:, :, :3], 2)  # BGRA2RGB

        return image

わーい。できたー。

改修前はレイヤー順に依存しているため前面にあるRenderDocの画面がキャプチャされてしまっていますが、改修後は前面にRenderDocが居座っても、ゲーム画面だけキャプチャされていますね。

改修前
改修後

キャプチャできたけど余白が発生する

MSSではぴったり切り抜きな領域取得関数ですが、BitBltだと左端に余白が生まれています。
偶然かと思い、幾つかのタイトルで試してみましたが、同様でした。

この問題ですが、古の記憶でなーんか思い当たる節があったんですよね。

マジでなんの関連か忘れましたが、なんか差分があるんですよ、これ。
おそらくウィンドウキャプチャのライブラリ選定していた頃の記憶なのですが、なんだっけなぁ・・・

マジで思い出せないけど、薄い記憶を頼りに適当に関数をぶち込んだら上手くいきました。

ReinLib/utility/rein_win32.py at 4567a7b4d575119a1535dc258495f27e2ae5b370 · kafues511/ReinLib
ReinLib. Contribute to kafues511/ReinLib development by creating an account on GitHub.
BitBlt function (wingdi.h) - Win32 apps
The BitBlt function performs a bit-block transfer of the color data corresponding to a rectangle of pixels from the spec...
    def get_scene_color3(self, hwnd:int, is_foreground:bool = True) -> npt.NDArray[np.uint8]:
        extended_frame_bounds = BoundingBox(*get_extended_frame_bounds(hwnd))
        window_rect = BoundingBox(*get_window_rect(hwnd))

        offset_x = extended_frame_bounds.xmin - window_rect.xmin
        offset_y = extended_frame_bounds.ymin - window_rect.ymin

        # get the window image data
        wDC = win32gui.GetWindowDC(hwnd)
        dcObj = win32ui.CreateDCFromHandle(wDC)
        cDC = dcObj.CreateCompatibleDC()
        dataBitMap = win32ui.CreateBitmap()
        dataBitMap.CreateCompatibleBitmap(dcObj, extended_frame_bounds.width, extended_frame_bounds.height)
        cDC.SelectObject(dataBitMap)
        cDC.BitBlt((0, 0), (extended_frame_bounds.width, extended_frame_bounds.height), dcObj, (offset_x, offset_y), win32con.SRCCOPY)

        # convert the raw data into a format opencv can read
        signedIntsArray = dataBitMap.GetBitmapBits(True)
        image = np.frombuffer(signedIntsArray, dtype=np.uint8).reshape((extended_frame_bounds.height, extended_frame_bounds.width, 4))

        # free resources
        dcObj.DeleteDC()
        cDC.DeleteDC()
        win32gui.ReleaseDC(hwnd, wDC)
        win32gui.DeleteObject(dataBitMap.GetHandle())

        # drop the alpha channel
        image = np.flip(image[:, :, :3], 2)  # BGRA2RGB

        return image

左端に余白が生まれることなく撮れていますね。

6枚中4枚は同様のゲームエンジン使っているはずなので、差分として意味は無いなと撮影後に思いました。
撮り直しは怠いので見なかったことにします。

参考

雑談

WinAPIって業務外でも意外と使うのよね。
と思ったけど、Windows使ってりゃそこに帰着するのは自然か。

馴染み深いものだから割とすぐに実装できてコスパ良かったでした。

あと、読み上げツールはPythonからC++/C#にお引越ししたいと思っていたので、キャプチャ層をWinAPIで書けるようになったのは、先のことを考えるとタイミングが良かったかもしれないです。

セレオブ 体験版 読み上げツール編

MSゴシックのモデルを使ったのですが『線引きを誤り』を『線引きを誤0』や『線引きを誤n』と誤分類しています。

――そんなはずはない。

この子が間違いを犯す確率はバカ低いんですから。

モヤモヤした気持ちを抱いてFontViewerを起動。

再度スクショしてフォントを当てはめると――。

MSゴシック
源ノ角ゴシック Medium

MSゴシックじゃなくて 源ノ角ゴシック Medium じゃん

圧倒的ヒューマンエラーでした。

仕方がないので休日のスヤスヤタイムにトレーニングしてもらうことにしました。
GPUファンx3の音がうるせぇ。

モデルを源ノ角ゴシック Mediumに変えたら無事解決しました。

よかった。

サマポケと同じでウィンドウ名が変わるタイプか。

たしか親のウィンドウハンドルを追跡しておけばいいんだけど、未実装なのよねぇ。

ウィンドウ名の部分一致で乗り切るか、大人しく実装するか、発売日までに対応しておかないとですね。

セレオブ 体験版 感想編

初手から可哀想な始まりで涙が出ますよ。

もー。オトメきと似たような導入じゃん。

創作世界はもっとハッピーであってほしいと願う筆者さんです。

始まりはそれぞれですが、最終的にはイチャイチャしてハッピーエンドなので、そこはとっても好きなポイントです。
喪失系もあるけど、アレ、病む。マジで。仕事に支障が出るレベル。

――以上です。

はい?遊ぶ時間?

作業内容によってはバックグラウンドで流せないのよね。

既存実装の延長は記憶と感覚で進められるから脳のリソースに余裕があってゲーム出来るんだけど、新しく勉強していることだったりは、リソース使用率100%に近いから難しいのよね。

頑張れば遊べるけど、それするくらいなら大人しく実装に全振りしたい筆者さんなの。

そういう時は同じ音楽、曲を永遠とループ再生してますね。
頭に残っている音なら環境音と同じ扱いなので、リソース割かれないですからね。
理論的ではなく、体感的な話ですけど。

最近だと1週間ぐらい同じ曲聴いてるかも。
音楽聴きながら作業したいけど効率悪いなぁと感じている方は、同じ曲を永遠とループ再生するのおすすめです。
もちろん、相性もあると思うから、あくまで筆者のスタイルの共有程度ね。

こういうコード書く方の趣味というか負債の返済を頑張っているので、セレオブの体験版は諦めました。

どーせ、ソフマップBoothげっちゅ屋駿河屋などから届くから、大人しく待って製品版で初見プレイしますわ。

天使騒々 アクリルプレート コンプリート編

いえーい。

みてみてー。

茉優先輩のアクリルブロックがいまいちな過去を経験したから、安い方のアクリルプレートをお試しで買ってみた。

お試しとは言ったもののとりあえず全部買ったんだけどね。

ユノスは1か月ぐらい遅延あるから怠いんだよねぇ。

感想としてはブロックよりプレートの方がいいと思った。

奥行きがないから見た目に対するしょぼさをあんまり感じない。

ブロックは裏にペッて貼ってあるだけで、なんの装飾もない透明な空間が広がってるから、なんかしょぼく感じちゃったのね。

中身にキラキラしたものでも散らしておけば多少見栄えが良くなるんだろうけど。

というわけでプレートは満足な買い物でした。

過去作もプレート出してほしいな。

そしたら買うんだけど。チラチラ。

ルックの研究をするために円盤を初購入

筆者の住処にはテレビがありません。

現代っ子なのでモニターのみです。

昔はPCにDVDドライブも付けていたのですが、最近は使用頻度がめっきり減ったので外付けにランクダウンです。

そして悲報です。

外付けの子、ブルーレイ非対応でした。

でもうちの子えらいです。

そんな仕様を知らないバカな筆者がウキウキで円盤をぶち込んでも、これ食えねぇ!!って吐き出してくれました。

壊したかと思ってマジで焦りました。

という訳でパパっと調べてパイオニアの外付けをパパっと購入。

無事見ることが出来ました。

最近のアニメは知らないのですが、マジでベタ塗りなのね。

グラデーションもほぼなくて驚きました。

学生時代はそういう目で見てなかったので気付かなかったです。

明暗と輪郭線が主な構成要素なんですかね。

はぇ~。

色塗りのコストを落とすためだと思うけど、工夫されている雰囲気を感じましたわ。
知らんけど。

最近はImage to Imageで色塗りもランタイムで出来るような技術が確立しつつあるので、ゲームエンジンにDCCで作成したルックを落とし込むフローが消えるんじゃないかと思いつつも、仕事と趣味は別だから流行りとか気にせずに好きなことしよー、と振り切っている筆者さんです。

遅かれ早かれそうなるとは思いますけどね。

深層学習が最近になって流行りだした理由もハードウェアが追い付いてきただけですし、ニューラルネットっていう技術自体は結構昔からありますからね。

ゲームハードが時代に追い付けば、そういう世界線が来ることもあるでしょうね。

おわり

前回作成したUEにUnityのTonemapper Mode Noneを組み込んだ改造がルック制作に大活躍しております。

指定した色が概ねそのまま出力されるのはやっぱり便利ですわ。

ライティングが非現実的なルックを作る場合には、まだまだエンジン改造が有利ですね。

まぁそれやる必要があるならUnreal Engineじゃなくて、Unityを使えよって話なんだけどね。

年齢制限区間で告知することでもないけど、次回はNeutralとACESを組み込んでみた投稿する予定です。

ACESはワンチャン、UEと機能被るから割愛するかもだけど。

できればそのタイミングで線画(輪郭線)パスをお披露目出来たらなと思っていたり、いなかったり。

最近はコード書く火力が体感上がっているので、とっても気分がいいです。

やっぱり私欲を以前より満たせているのでそのバフが利いているのかもですね。

仮に利いていなくても効いていると思い込むことは大事なことですよ。

子供でも大人でも他人に褒められることなんてほぼないのが現実世界ですから、自分で自分にいい子いい子して、自己肯定感を高めることはとっても大事なのです。

現に筆者は自分のことが大好きで、それで人生、大成功とは言えませんが、衣食住に困らない日々を送れていますわ。

思い込みというは良い方向にも悪い方向にも作用するので、良い方向に持っていて他者に迷惑を掛けない程度に、自分だけの世界を楽しみましょ。

それじゃ、コードの続きを書きたいので、ばいばい。