mieki256's diary



2022/08/14() [n年前の日記]

#2 [python][pygame][windows] pygameでWindows用スクリーンセーバを作る

Windows10 x64 21H2 + Python 3.8.10 64bit + pygame 1.9.6 を使って、Windows用のスクリーンセーバが作れそうかどうか試してみた。

動作画面は以下のような感じ。解像度が低過ぎてアレだけど、雰囲気ぐらいは分かるかなと。また、かなり後で話が出てくるけれど、プレビューが表示されるまで妙に待たされてることも分かるかと思う。

制限事項。 :

pygame でWindows用スクリーンセーバを作成するにあたって、いくつか制限事項(?)がある。
  • pygame は 1.9.x であること。pygame 2.x.x は、環境変数 SDL_WINDOWID が反映されないので作れない。(pygame 1.x.x はSDL1.xを、pygame 2.x.x はSDL2を使っていて、SLD2 は件の環境変数が反映されない。)
  • pygame 1.9.x の最終バージョン、pygame 1.9.6 は、Python 3.8 までの対応なので、pygame 1.9.6 を使いたいなら、Python 2.7 - 3.8 のどれかで作ることになる。

Windows用スクリーンセーバについてのおさらい。 :

Windows用スクリーンセーバ(.scr)はどんな仕様を要求されるのか、念のために再度列挙してみる。

  • Windows用スクリーンセーバは、.exe を .scr にリネームしたもの。

コマンドラインオプションについて。
  • .scr は、3種類のコマンドラインオプション、「/s」「/c:xxxx」「/p xxxx」のどれかが渡されて実行される。
  • .scrファイルを右クリックして出てくるメニューから、「テスト」「構成」等を選んだ場合、コマンドラインオプションがつかない状態で実行される場合もある。
  • /s、/c、/p は、大文字だったり小文字だったりするので、プログラム側で大文字化、または小文字化をしてから判別したほうが良い。
  • /s はフルスクリーン表示モード。キーボード操作やマウス操作を検出して、操作があったら自分で終了するように作っておく。
  • /c:xxxx は、そのスクリーンセーバの設定ダイアログ表示モード。本来は設定内容を .ini やレジストリ等に記録することになるけれど、特に設定項目が無いなら、スクリーンセーバ名を表示するダイアログが出るだけで十分。
  • /p xxxx は、スクリーンセーバ設定画面のプレビュー窓の中に表示するモード。xxxx はプレビュー窓のウインドウハンドル。

「/p xxxx」にも対応させることを考えると、以下の条件を満たすプログラミング言語/ライブラリ/フレームワークなら、Windows用スクリーンセーバを作れることになる。
  • 与えられたウインドウハンドルからウインドウサイズを取得できて、
  • そのウインドウハンドルのウインドウに対して描画ができる。

多重起動禁止処理について。
  • スクリーンセーバ設定画面で何かしら操作をすると、その都度 /p xxxx つきでスクリーンセーバが何度も何度も実行されるので、特に何もしないと同じ処理をするプロセスが2つも3つも走ってしまう。多重起動を抑止する処理が必要になる。
  • スクリーンセーバが実行されるまでの時間の間隔で、スクリーンセーバが何度も実行される可能性がある。フルスクリーン表示時も、一応、多重起動の禁止処理を入れておいたほうが安心。

インストール場所について。
  • Windows が64bit版、スクリーンセーバが32bit版のプログラムなら、C:\Windows\SysWOW64\ 以下に .scr をコピーする。
  • Windows が64bit版、スクリーンセーバが64bit版のプログラムなら、C:\Windows\System32\ 以下に .scr をコピーする。
  • Windows が32bit版なら、C:\Windows\System32\ 以下に .scr をコピーする。

開発環境。 :

今回は、Windows10 x64 21H2上で Python 3.9.13 64bit版や 3.8.10 64bit版をインストールしてある状態で、virtualenv を使って Python 3.8 64bit の開発環境を用意してから作業した。

後々、PyInstaller や Nuitka を使って、.py ファイルをexe化するわけだけど。不要なモジュールまで exe に含められるのは困るので、必要最低限のモジュールしか入っていないクリーン(?)な環境を用意できたほうが exe化するには都合がいい。まあ、virtualenv ではなく、venv 等を使っても同じことはできると思うけど…。

virtualenv のインストールと、Python 3.8 の開発環境を作成。
python -m pip install -U setuptools virtualenv
virtualenv -p python3.8 venv38

開発環境の有効化。
.\env38\Scripts\activate

Pythonのバージョンを確認。
python -V
(venv38) ... > python -V
Python 3.8.10

ちなみに、開発環境の無効化は以下。
deactivate

必要なモジュールをインストール。 :

スクリーンセーバを作る際に必要になるモジュールをインストール。pygame、pywin32 と、exe化するための何かしらが入っていれば済む。
python -m pip install pygame==1.9.6
python -m pip install pywin32
python -m pip install pyinstaller
python -m pip install nuitka zstandard orderedset

手元の開発環境では、以下が入っている。余計なモジュールも入ってるけど…。(Pythonスクリプトの整形ツール、autopep8 とか…。)
(venv38) ... > python -m pip list

Package                   Version
------------------------- ---------
altgraph                  0.17.2
autopep8                  1.7.0
cachetools                5.2.0
future                    0.18.2
Nuitka                    1.0.3
orderedset                2.0.3
pefile                    2022.5.30
pip                       22.2.2
pycodestyle               2.9.1
pygame                    1.9.6
pyinstaller               5.3
pyinstaller-hooks-contrib 2022.8
pywin32                   304
pywin32-ctypes            0.2.0
setuptools                63.4.1
toml                      0.10.2
wheel                     0.37.1
zstandard                 0.18.0

ソース。 :

Python 3.8 + pygame 1.9.6 + pywin32 で作成した、Windows用スクリーンセーバのソースは以下。画面の中を赤いボールが跳ね回るだけの、いつもの処理内容。

_scrsavpygame.py
import ctypes
import datetime
import math
import os
import sys
import time
import win32api
import win32event
import win32gui
import win32security
import winerror

DBG = False

IMG_NAME = "resources/ball_64x64.png"

PREVIEW_BG_IMG = "resources/preview_bg.png"
PREVIEW_LOGO_IMG = "resources/preview_logo.png"

APPLI_NAME = "screensaver sample by using pygame"
VER_NUM = "0.0.1"

MUTEX_NAME_FILLSCR = "screensaver_pygame_fullscr"
MUTEX_NAME_CONFIG = "screensaver_pygame_config"
MUTEX_NAME_PREVIEW = "screensaver_pygame_preview"


def resource_path(filename):
    """Get resource file path."""
    if hasattr(sys, "_MEIPASS"):
        # use PyInstaller
        base_dir = sys._MEIPASS
    else:
        base_dir = os.path.abspath(".")

    return os.path.join(base_dir, filename)


def full_screen(screen):
    """drawing pygame window."""

    # invisivle mouse cursor
    pygame.mouse.set_visible(False)

    # load image
    img = pygame.image.load(resource_path(IMG_NAME)).convert()
    img.set_colorkey(img.get_at((0, 0)), pygame.RLEACCEL)

    # initialize work
    scrw, scrh = screen.get_width(), screen.get_height()
    x, y = scrw / 2, scrh / 2
    dx = float(scrw) / (60 * 2)
    dy = float(scrh) / (60 * 3)

    clock = pygame.time.Clock()
    counter = 0
    looping = True

    # main loop
    while looping:

        # check key, mouse
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                looping = False
            if event.type == pygame.KEYUP:
                # any key up
                looping = False
            if event.type == pygame.MOUSEMOTION:
                # move mouse
                if counter >= 120:
                    looping = False

        if not looping:
            break

        # update sprite position
        w, h = img.get_width(), img.get_height()
        x += dx
        y += dy
        if x <= (w / 2) or x >= scrw - (w / 2):
            dx *= -1
        if y <= (h / 2) or y >= scrh - (h / 2):
            dy *= -1

        # clear bg
        screen.fill((16, 16, 16))

        # draw sprite
        px, py = int(x - (w / 2)), int(y - (h / 2))
        screen.blit(img, (px, py))

        counter += 1

        pygame.display.flip()
        clock.tick(60)

    # visible mouse cursor
    pygame.mouse.set_visible(True)


def preview(screen):
    frames = 360 * 2

    bgimg = pygame.image.load(resource_path(PREVIEW_BG_IMG)).convert()
    logoimg = pygame.image.load(resource_path(PREVIEW_LOGO_IMG)).convert()
    logoimg.set_colorkey(logoimg.get_at((0, 0)), pygame.RLEACCEL)

    clock = pygame.time.Clock()
    ang = 0
    looping = True
    for i in range(frames):
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                looping = False
            if event.type == pygame.KEYDOWN:
                if event.key == pygame.K_ESCAPE:
                    looping = False

        if not looping:
            break

        scrw, scrh = screen.get_width(), screen.get_height()

        screen.fill((100, 150, 200))
        iw, ih = bgimg.get_width(), bgimg.get_height()
        px = int((scrw - iw) / 2)
        py = int((scrh - ih) / 2)
        screen.blit(bgimg, (px, py))

        iw, ih = logoimg.get_width(), logoimg.get_height()
        px = int((scrw - iw) / 2)
        py = int((scrh * 0.15) * math.sin(math.radians(ang)) + (scrh / 2) - (ih / 2))
        screen.blit(logoimg, (px, py))

        ang += 3

        pygame.display.flip()
        clock.tick(60)


def get_window_size(hwnd):
    """Get window size."""
    rect = win32gui.GetWindowRect(hwnd)
    x0, y0, x1, y1 = rect[0], rect[1], rect[2], rect[3]
    return ((x1 - x0), (y1 - y0))


def msg_box(msg, title):
    """Display MessageBox by Windows API."""
    user32 = ctypes.WinDLL("user32")

    # user32.MessageBoxW.restype = ctypes.c_int32
    # user32.MessageBoxW.argtypes = (ctypes.c_void_p, ctypes.c_wchar_p,
    #                                ctypes.c_wchar_p, ctypes.c_uint32)

    user32.MessageBoxW(0, msg, title, 0x00000040)


def write_log(s):
    global DBG
    if DBG:
        desktop_dir = os.path.expanduser('~/Desktop')
        log_file = os.path.join(desktop_dir, "temp.log.txt")
        dt_now = datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S")
        with open(log_file, 'a') as f:
            f.write("[%s] %s\n" % (dt_now, s))


def get_mutex(name):
    sa = win32security.SECURITY_ATTRIBUTES()
    sa.SECURITY_DESCRIPTOR.SetSecurityDescriptorDacl(True, None, False)
    mtx = win32event.CreateMutex(sa, False, name)
    err = win32api.GetLastError()
    if not mtx or err == winerror.ERROR_ALREADY_EXISTS:
        return (mtx, False)
    return (mtx, True)


def close_mutex(mtx):
    global cmdopt
    if mtx:
        # win32event.ReleaseMutex(mtx)
        # win32api.CloseHandle(mtx)
        mtx.Close()
        write_log("%s : Close mutex. Exit." % cmdopt)


# ----------------------------------------
# parse commandline option
kind = "None"
hwnd = 0
if len(sys.argv) >= 2:
    s = sys.argv[1][0:2].lower()
    if s == "/p":
        kind = "preview"
        hwnd = int(sys.argv[2])
    elif s == "/c":
        kind = "config"
    elif s == "/s":
        kind = "fullscreen"

cmdopt = " ".join(sys.argv)

if kind == "config":
    # ----------------------------------------
    # config / setting
    mtx, success = get_mutex(MUTEX_NAME_CONFIG)
    if not success:
        # Process exists
        write_log("%s : %s already exists. Exit." % (cmdopt, MUTEX_NAME_CONFIG))
        sys.exit(0)

    # new process
    write_log("%s : New process." % cmdopt)
    msg_box("%s\nver.%s" % (APPLI_NAME, VER_NUM), "Information")
    close_mutex(mtx)
    sys.exit()

if kind == "preview":
    # ----------------------------------------
    # preview
    mtx, success = get_mutex(MUTEX_NAME_PREVIEW)
    if not success:
        write_log("%s : %s already exists. Exit." % (cmdopt, MUTEX_NAME_PREVIEW))
        sys.exit(0)

    os.environ['SDL_VIDEODRIVER'] = 'windib'

    if hwnd != 0:
        # set SDL_WINDOWID
        os.environ['SDL_WINDOWID'] = str(hwnd)
        wdw_size = get_window_size(hwnd)
        write_log("Windows size: %d x %d" % (wdw_size))
    else:
        wdw_size = (152, 112)

    write_log("%s : New process. hwnd = %d , Wdw size = %d x %d" %
              (cmdopt, hwnd, wdw_size[0], wdw_size[1]))

    import pygame  # nopep8

    pygame.init()
    screen = pygame.display.set_mode(wdw_size)
    preview(screen)
    pygame.quit()
    close_mutex(mtx)
    sys.exit()

# ----------------------------------------
# full screen
mtx, success = get_mutex(MUTEX_NAME_FILLSCR)
if not success:
    # Process exists
    write_log("%s : %s already exists. Exit." % (cmdopt, MUTEX_NAME_FILLSCR))
    sys.exit(0)

# New process
write_log("%s : New process." % cmdopt)

import pygame  # nopep8

pygame.init()
screen = pygame.display.set_mode((0, 0), pygame.FULLSCREEN)
full_screen(screen)
pygame.quit()
close_mutex(mtx)
sys.exit()

使用画像は以下。scrsavpygame.py と同階層に、resources というディレクトリを作成して、その中に入れておく。

_ball_64x64.png
_preview_bg.png
_preview_logo.png
(venv38) ... > tree /A /f

D:.
|   scrsavpygame.py
|
+---resources
        ball_64x64.png
        preview_bg.png
        preview_logo.png

ソースについて少し説明。 :

/p xxxx を渡して実行した時(プレビューモード)の動作は、一定時間アニメーションを表示して、その後終了する作りにしてみた。

当初、与えられたウインドウハンドルに対して何かを描画して即座に終了する処理で十分だろうと思っていたのだけど。その方法を実際に試してみたら、描画内容が全く反映されず。どうやら数秒ほど延々と描画を続けてみないと、見た目が反映されないようだなと…。何故かは分からないけれど…。

しかし、そのままいつまでもアニメーションを描画し続けるわけにもいかなくて。スクリーンセーバ設定画面が閉じられた際、スクリーンセーバも終了しなければいけないはずだけど、どうすればスクリーンセーバ設定画面の終了を検出できるのか、そこが分からない。仕方なく、一定時間が経過したら問答無用で終了してしまうことにしてお茶を濁した。このあたり、解決策はあるのだろうか…?


多重起動抑止については、以下を参照のこと。

_Pythonスクリプトの多重起動を抑止したい


環境変数 SDL_WINDOWID を使って、pygame のウインドウを埋め込む処理については、以下を参照のこと。

_tkinterの中にpygameを埋め込む

Pythonスクリプトとして動作確認。 :

動作確認は以下。
python scrsavpygame.py /s
python scrsavpygame.py /c
python scrsavpygame.py /p 0
python scrsavpygame.py

「/p 0」を渡したときは、仮で小さなウインドウを生成して、その中にプレビュー窓用の内容をテスト描画するようにしてみた。本番では、例えば「/p 1049354」のように、何かしらの大きな数値(実在するウインドウハンドル)が渡されるはず。

exeに変換。 :

Windows用スクリーンセーバとして利用するためには、ファイルの拡張子が .scr でなければならず、その .scr は .exe をリネームして作られるわけで…。つまり、この Pythonスクリプトをexe化しないと、スクリーンセーバとして利用できない。

今回は PyInstaller 5.3 を使ってexe化してみた。
python -m pip install pyinstaller -U

まずは、以下を打って変換する。
pyinstaller --onefile --noconsole --noupx scrsavpygame.py

distディレクトリが作られて、その中に scrsavpygame.exe というファイルが出来上がった。また、scrsavpygame.spec というファイルも生成された。

ただ、この状態では、動作に必要な画像ファイル群が ―― resources/*.png が同梱されていない。

scrsavpygame.spec を編集して、画像ファイル群も同梱させる指定をする。

exe = EXE(
    pyz,
    a.scripts,
    a.binaries,

↓

exe = EXE(
    pyz,
    Tree('resources',prefix='resources'),
    a.scripts,
    a.binaries,

「Tree('resources',prefix='resources'),」という行を挿入することで、「resourcesディレクトリも同梱せよ」と指示してる。

今度は、.specファイルを指定してexe化する。
pyinstaller scrsavpygame.spec

dist/scrsavpygame.exe を実行して動作確認をしておく。
scrsavpygame.exe /s
scrsavpygame.exe /c
scrsavpygame.exe /p 0
scrsavpygame.exe

動作したら、.scr にリネームコピー。
copy scrsavpygame.exe scrsavpygame.scr

出来上がった .scr を、既定の場所にコピーしてインストールする。
  • Windows が64bit版、スクリーンセーバが32bit版のプログラムなら、C:\Windows\SysWOW64\ 以下に .scr をコピーする。
  • Windows が64bit版、スクリーンセーバが64bit版のプログラムなら、C:\Windows\System32\ 以下に .scr をコピーする。
  • Windows が32bit版なら、C:\Windows\System32\ 以下に .scr をコピーする。

スクリーンセーバとして動作確認。 :

スクリーンセーバの設定画面を呼び出して、「scrsavpygame」が増えているか確認する。Windows10における、スクリーンセーバ設定画面の呼び出し方は以下。
  • デスクトップの何もないところを右クリック → 個人用設定 → ロック画面 → スクリーンセーバー設定。
  • あるいは、Windows10の左下の検索欄に「スクリーンセーバー」と打てば、「スクリーンセーバーの変更」という項目が出てくるので、それを選んでもいい。

スクリーンセーバ設定画面が表示されるまで、妙に待たされた気もするけれど、それでも一応動作してくれた。

そんなわけで、Python 3.8 + pygame 1.9.6 + pywin32 を使ってWindows用スクリーンセーバを作れそうだ、と分かった。

問題点。 :

一応それらしく動いたものの。現状では、ちょっと問題が…。

スクリーンセーバの設定画面で「scrsavpygame」を選ぶと、プレビュー窓の中に何かが表示されるまで随分待たされる…。おそらくだけど、以下が原因なのではなかろうか。
  • PyInstaller で作成された .exe は、毎回一時フォルダに解凍する処理が走るので、起動がとにかく遅い。
  • 毎回、Windows Defender によるウイルスチェックで時間がかかっているのかもしれない。

更に、出来上がった .scr に対して、Windows Defender が「これはウイルスだ!」と誤判定してきた。

PyInstaller を使って生成した .exe はウイルスとして誤判定されやすいらしい。巷のウイルスが PyInstaller等を使って作られているせいで、ウイルス対策ソフトが「PyInstaller で作られた .exe はウイルス」と決めつけてる節があるそうで。

もちろん、こうして自分で作ったプログラムだから、ウイルスなわけもなく…。Windows Defender に「このファイルはウイルス判定を除外せよ」と設定して使うしかなさそう。

ただ、出来上がった .scr を配布したいと思った場合はフツーに困る…。ダウンロードした人達が「これ、ウイルスじゃん!」と慌てちゃうだろうな…。

Nuitkaでexe化してみる。 :

Pythonスクリプトをexe化するツールは、PyInstaller、py2exe、cx_Freeze など色々あるけれど。Pythonスクリプトを一旦C言語のソースに変換して、gccでコンパイルしてexeを作ってくれる Nuitka なるツールがあるそうで。PyInstaller で作ったexeより起動が速く、ファイルサイズも小さくなる傾向があるとのこと。興味が湧いた。試してみる。

インストールは以下。
python -m pip install nuitka zstandard orderedset

自分の環境では、orderedset というモジュールもインストールしないと、処理が先に進まなかった。

Nuitka用に修正したソースは以下。

_scrsavpygame.py

変更点は2ヵ所。
  • 一行目のシバン(shebang)を、「#!python」から「#!/usr/bin/python3.8」に変更。この変更をしないと、「Pythonのバージョンが違う」と文句を言ってきて処理が進まなかった。
  • 画像ファイル(リソースファイル)を読み込む際の、ファイルパスの求め方を修正。

リソースファイルパスを求める部分は以下になる。
def resource_path(filename):
    """Get resource file path."""
    if False:
        if hasattr(sys, "_MEIPASS"):
            # use PyInstaller
            base_dir = sys._MEIPASS
        else:
            base_dir = os.path.abspath(".")
    else:
        base_dir = os.path.dirname(__file__)
    return os.path.join(base_dir, filename)
「if False:」で無効化してあるところが、PyInstaller、あるいは通常のPythonスクリプトとして実行する際の処理。Nuitka用は、os.path.dirname(__file__) で基準ディレクトリ(?)を求めることになる模様。

exeへの変換は以下。
python -m nuitka --mingw64 --windows-disable-console --include-data-file=".\\resources\\*.png=.\\resources\\" --onefile scrsavpygame.py
  • python -m nuika : nuika の呼び出し。
  • --mingw64 : mingw64 (gcc) を使うことを指定。
  • --windows-disable-console : exe実行時にコンソールを非表示にする。
  • --include-data-file=".\\resources\\*.png=.\\resources\\" : リソースファイル(*.png)を同梱させる。
  • --onefile : リソースファイルも含めて1つのexeファイルにする。
  • scrsavpygame.py : exe化したいPythonスクリプト名。
ちなみに、リソースファイルは、--include-data-file=".\\resources\\*.png=.\\resources\\" で指定しているけれど。最初、パス区切り文字を「/」にしたら上手く行かなくて、試行錯誤中に「\\」にしたら何故か上手く行った。ただ、これは、スクリプト側の、リソースファイルパスを求める処理がおかしかった可能性がある。

上記を実行したところ、途中で「gccその他が見つからないがダウンロードするか?」と尋ねてきた。「Yes」を入力してダウンロードすることにした。それぞれ、 C:\Users\(USERNAME)\AppData\Local\Nuitka\ 以下にキャッシュとしてダウンロードされる模様。ファイル数を調べてみたら、7923ファイル、全体で1.14GBが入っていた。まあ、Nuitka が要求する gccのバージョン等が固定されている可能性もあるので、このくらいは目を瞑ったほうが面倒臭くないのかもしれない。

数分かかって、.py と同階層に scrsavpygame.exe が作成された。ファイルサイズは 8.59MB。PyInstaller で生成した .exe は 13.06MB だったので、若干ファイルサイズも小さくなっている。

別ディレクトリに .exe をコピーして実行してみたところ、動作してくれた。
scrsavpygame.exe /s
scrsavpygame.exe /c
scrsavpygame.exe /p 0
scrsavpygame.exe

なんとなくだけど、PyInstaller で作った .exe より起動がちょっと速いような気もする。

スクリーンセーバとして利用できるか動作確認。.exe を .scr にリネームコピーして、スクリーンセーバの既定の置き場所にコピー。スクリーンセーバ設定画面を出してリストから選んだ。これも一応動いてくれた。

ただ、相変わらず、プレビュー窓内が表示されるまで結構待たされる…。こうなると、一時ディレクトリを作成して、動作に必要なファイルを全て展開してから実行するという、その仕組み自体が厳しいのかもしれない…。

ちなみに、--onefile ではなく --standalone を指定すれば、1つのファイルにまとめずに、*.dist というディレクトリに動作に必要なファイルを全てコピーした状態で .exe を生成してくれる。ファイル数は106ファイル、27.52MB だった。

つまり、1つの .exe にした場合、実行時は毎回106ファイルを一時ディレクトリに展開しているわけで…。それなりに起動が遅くて当然ですよね、という気もしてきた。

参考ページ。 :


以上です。

過去ログ表示

Prev - 2022/08 - Next
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31

カテゴリで表示

検索機能は Namazu for hns で提供されています。(詳細指定/ヘルプ


注意: 現在使用の日記自動生成システムは Version 2.19.6 です。
公開されている日記自動生成システムは Version 2.19.5 です。

Powered by hns-2.19.6, HyperNikkiSystem Project