🙀

マルチディスプレイ対応壁紙スクリプトのお話

に公開

マルチディスプレイ環境でWindowsの壁紙をディスプレイごとにランダムに貼りたいかな、ということで何年も前に見よう見まねで書いたPythonスクリプトを使ってました。当時はそういうツールがなく(見つけられず)、いろいろ調べながら書いた覚えがあります。

そんな古いスクリプトをGoogleのAIサービスGeminiの最新モデルGemini 2.5 Proに投げてブラッシュアップしてもらったら……という小ネタです。

原型のスクリプト

何年か前に書いた原型のスクリプトは次のようなものです。書いて以来、ずっと私的に使っていて特に問題もなく動いてました。

import codecs
import sys
import os
import random
from ctypes import HRESULT, POINTER, pointer
from ctypes.wintypes import LPCWSTR, UINT, LPWSTR

import comtypes
from comtypes import IUnknown, GUID, COMMETHOD, Structure, c_int, COMError
import pathlib

class POINT(Structure):
    _fields_ = [("x", c_int),
                ("y", c_int)]

class RECT(Structure):
    _fields_ = [("upperleft", POINT),
                ("lowerright", POINT)]

class IDesktopWallpaper(IUnknown):
    _iid_ = GUID('{B92B56A9-8B55-4E14-9A89-0199BBB6F93B}')

    @classmethod
    def CoCreateInstance(cls):
        class_id = GUID('{C2CF3110-460E-4fc1-B9D0-8A1C0C9CC4BD}')
        return comtypes.CoCreateInstance(class_id, interface=cls)

    _methods_ = [
        COMMETHOD(
            [], HRESULT, 'SetWallpaper',
            (['in'], LPCWSTR, 'monitorID'),
            (['in'], LPCWSTR, 'wallpaper'),
        ),
        COMMETHOD(
            [], HRESULT, 'GetWallpaper',
            (['in'], LPCWSTR, 'monitorID'),
            (['out'], POINTER(LPWSTR), 'wallpaper'),
        ),
        COMMETHOD(
            [], HRESULT, 'GetMonitorDevicePathAt',
            (['in'], UINT, 'monitorIndex'),
            (['out'], POINTER(LPWSTR), 'monitorID'),
        ),
        COMMETHOD(
            [], HRESULT, 'GetMonitorDevicePathCount',
            (['out'], POINTER(UINT), 'count'),
        ),
        COMMETHOD(
            [], HRESULT, 'GetMonitorRECT',
            (['in'], LPCWSTR, 'monitorID'),
            (['out'], POINTER(RECT), 'displayRect'),
        ),
    ]

    def SetWallpaper(self, monitorId: str, wallpaper: str):
        self.__com_SetWallpaper(LPCWSTR(monitorId), LPCWSTR(wallpaper))

    def GetWallpaper(self, monitorId: str) -> str:
        wallpaper = LPWSTR()
        self.__com_GetWallpaper(LPCWSTR(monitorId), pointer(wallpaper))
        return wallpaper.value

    def GetMonitorDevicePathAt(self, monitorIndex: int) -> str:
        monitorId = LPWSTR()
        self.__com_GetMonitorDevicePathAt(UINT(monitorIndex), pointer(monitorId))
        return monitorId.value

    def GetMonitorDevicePathCount(self) -> int:
        count = UINT()
        self.__com_GetMonitorDevicePathCount(pointer(count))
        return count.value

    def GetMonitorRECT(self, monitorId: str) -> bool:
        rect = RECT()
        try:
            self.__com_GetMonitorRECT(LPCWSTR(monitorId), pointer(rect))
        except COMError as ce:
            return False
        return True;


def main(wallPath: str):
    fd = sys.stdout
    logfile = os.path.dirname(__file__) + "/wall.txt"
    try:
        fd = codecs.open(logfile, mode="w", encoding="utf-8")
    except ValueError:
        fd = sys.stdout

    walldir = pathlib.Path(wallPath)
    wallpapers = [*map(str, walldir.iterdir())]

    desktop_wallpaper = IDesktopWallpaper.CoCreateInstance()
    monitor_count = desktop_wallpaper.GetMonitorDevicePathCount()
    for i in range(monitor_count):
        monitor_id = desktop_wallpaper.GetMonitorDevicePathAt(i)
        if desktop_wallpaper.GetMonitorRECT(monitor_id):
            wallpaper = random.choice(wallpapers)
            print(str(i) + ":" + str(wallpaper), file=fd)
            desktop_wallpaper.SetWallpaper(monitor_id, str(wallpaper))

if __name__ == '__main__':
    WALLPAPER_PATH = "your_wallpaper_dir"
    main(WALLPAPER_PATH)

WALLPAPER_PATHに格納されている画像ファイルから、ランダムに選んだ画像ファイルを、接続されているディスプレイに壁紙としてセットするというPythonスクリプトです。Windows 8(だったと思う)からサポートされているIDesktopWallpaperインタフェースを使って、接続されているディスプレイごとに異なる壁紙をセットします。

このスクリプトを書いた当時はIDesktopWallpaperインタフェースを使っている例代わりと少なくて、いろいろ調べながら書く必要がありましたが、最近は割とポピュラーになってるみたいですね。

IDesktopWallpaperインタフェースを使う上での注意点は、IDesktopWallpaper::GetMonitorDevicePathCountがWindowsのシステムに登録されているすべてのディスプレイの数を返すことです。登録されているディスプレイは、たとえばディスプレイポートを繋ぎ変えたりするだけでも増えていくので、接続されていない複数のディスプレイが登録されていることがままあります。

IDesktopWallpaper::SetWallpaperは接続されていないディスプレイにも壁紙をセットできてしまいます。実害はないですが、少なくともディクスペースがその分だけ少し無駄になるということがあるので、接続されていないディスプレイは除外したほうがいいでしょう。

Microsoftのドキュメントにあるように、IDesktopWallpaper::GetMonitorRECTを呼ぶと接続されていないディスプレイならS_FALSEを返すので、それを使って接続されていないディスプレイを除外できます。

このスクリプトでも問題なく使うことは出来ます。Windowsで次のように実行するとWALLPAPER_PATHに設定されているディレクトリにある画像からランダムに選んだ画像を、個々のディスプレイの壁紙にセットしてくれます。

python wallpaper.py

よくわかってないですが、Windowsの背景の設定を「ランダム」に設定しておいて、このスクリプトを1度だけ実行すると、以降はディスプレイごとに異なる壁紙をランダムに貼ってくれるようになります。なので、このスクリプトは1度だけ実行すればあとは用無しになることも多いでしょう。

タスクスケジューラで定期的に実行することも可能ですが、その場合は壁紙ファイルが格納されているストレージが高速でないと実行時にそこそこの負荷がかかります。NVMeのSSDなら実用上は問題ない負荷ですが、SATAのHDDやSSDだと実行時に若干のラグが生じる程度の負荷が発生します。なのでストレージがNVMe SSDでないのならWindows起動時に1回実行する程度にとどめておいたほうがいいでしょう。

タスクスケジューラで実行する場合はコマンドウィンドウを開かないインタープリタpythonw.exeを使いましょう。でないと実行時にコマンドウィンドウが開いてかなり煩わしいです。

Geminiにブラッシュアップしてもらったら

Gemini 2.5 Pro(Experimental)というAIモデルがなかなか良さげということで、このスクリプトを試しにGemini 2.5 Proに投げつけてみたところ、Gemini 2.5 Proは次のような改良をささっとやってくれました。

  • エラー処理の追加。try~exceptで要所を囲ってCOMErrorなどの例外を捕捉しエラー表示するように変更
  • comtypes.CoInitialize()comtypes.CoUninitialize()の追加
  • 要所にコメントを追加

続けて「WALLPAPER_PATHをスクリプトにハードコードするのはユーザービリティが悪い。改良する案はない?」と提案したところ

  • 壁紙パスを保存するconfig.iniを追加
  • tkinterを使ったディレクトリ選択機能を追加

という改造までささっとやってくれました。

さらに初期壁紙ディレクトリを画像フォルダにしたいかな、と。Windowsの画像フォルダはデフォルトでは%USERPROFILE%\Picturesですが、ご存知の通りユーザーがカスタマイズ可能です。なので「画像フォルダやドキュメントフォルダのパスを得る方法はないの?」とGeminiに聞いてみたところ、こちらの意図を汲み取ってSHGetKnownFolderPathの呼び出しもささっと追加してくれました。SHGetKnownFolderPathなんていうAPIがあることすら知らんかった……

という具合で、たった10分で豪華絢爛なスクリプトに。ただし、こうやってGemini 2.5 Proがブラッシュアップしてくれたスクリプトはミスが散見され、そのままでは実行できませんでした。

まず、ctype.CTYPE_GUIDなど存在しないクラスをimportし使っていました。Geminiはctypesとcomtypesに定義されているクラスモジュールの知識が曖昧のようで、このようなミスがいくつかあり修正が必要でした。

そしてもうひとつ、FOLDERID_PicturesのGUIDが違っていた。正しくは{33E28130-4E1E-4676-835A-98395C3BC3BB}のところ{A990AE9F-A03B-4E80-94BC-9912D7504104}だと主張するんですね。Microsoftのドキュメント{33E28130-4E1E-4676-835A-98395C3BC3BB}と書いてあるよ、とGeminiに投げても頑固に「それはFOLDERID_CameraRoll」だと主張して譲らない。

Gemini 2.5 Proは思考過程を見ることができますが、

Check the provided link: Access the Japanese Microsoft documentation for KNOWNFOLDERID (provided by the user: https://learn.microsoft.com/ja-jp/windows/win32/shell/knownfolderid).
Search for FOLDERID_Pictures. The documentation confirms it is {A990AE9F-A03B-4E80-94BC-9912D7504104}.
Search for FOLDERID_CameraRoll. The documentation confirms it is {33E28130-4E1E-4676-835A-98395C3BC3BB}.

となっていて、正しくリンク先のドキュメントを検索できていない模様。なぜこういう事が起きるのかはわかりません。

というわけで色々ミスはあるが、やっぱり便利なもんですね。Windowsのような普段あまりコードを書かないプラットフォームでも、GeminiみたいなAIに助けてもらえばサクサクっとこの程度のツールは書ける。今回は土台となるコードをこちらから投げてますが、ゼロからでも書いてくれるでしょう多分。

最後にGeminiが書いてくれたコードをもとにした壁紙スクリプトを載せておきます。tkinterは蛇足にすぎるというか実行速度を落としそうなのでバッサリ切って、また不必要と思われるエラー表示なども一部省略しGeminiのコードを書き直したのが下のコードです。

初回実行時、スクリプトと同じディレクトリにconfig.iniがなければ新規作成して終了します。デフォルトのパスは画像フォルダになっていますので、必要に応じて書き換えて実行すれば、config.iniに設定したパスから画像ファイルを拾って壁紙として貼ってくれます。SHGetKnownFolderPathの使い方なんかは参考になるのではないかと。

import codecs
import sys
import os
import random
import pathlib
import traceback
import configparser
import ctypes
from ctypes import HRESULT, POINTER, pointer, Structure, c_int, c_wchar_p, POINTER, c_void_p, windll
from ctypes.wintypes import LPCWSTR, UINT, LPWSTR

import comtypes
from comtypes import IUnknown, GUID, COMMETHOD, Structure, c_int, COMError

# --- Configuration ---
CONFIG_FILE_NAME = "config.ini"
CONFIG_SECTION = "Settings"
CONFIG_OPTION = "WallpaperPath"

# --- Known Folder GUIDs ---
# https://learn.microsoft.com/ja-jp/windows/win32/shell/knownfolderid
# 画像フォルダGUID: {33E28130-4E1E-4676-835A-98395C3BC3BB}
FOLDERID_Pictures = comtypes.GUID('{33E28130-4E1E-4676-835A-98395C3BC3BB}')

# --- Windows API Prototypes ---
# SHGetKnownFolderPath
# https://learn.microsoft.com/ja-jp/windows/win32/api/shlobj_core/nf-shlobj_core-shgetknownfolderpath
try:
    shell32 = windll.shell32
    shell32.SHGetKnownFolderPath.argtypes = [
        POINTER(GUID),        # rfid: KNOWNFOLDERIDのポインタ
        ctypes.c_ulong,       # dwFlags: Flags (KNOWN_FOLDER_FLAG、default=0)
        c_void_p,             # hToken: アクセストークン(ない場合はカレントユーザー)
        POINTER(c_wchar_p)    # ppszPath: パス文字列を受け取るポインタ
    ]
    shell32.SHGetKnownFolderPath.restype = HRESULT # 返値

    # CoTaskMemFree
    # https://learn.microsoft.com/ja-jp/windows/win32/api/combaseapi/nf-combaseapi-cotaskmemfree
    ole32 = windll.ole32
    ole32.CoTaskMemFree.argtypes = [c_void_p]
    ole32.CoTaskMemFree.restype = None

    # SHGetKnownFolderPathが利用可能かどうかのフラグ
    _known_folder_api_available = True
except (AttributeError, OSError) as e:
    print(f"SHGetKnownFolderPathまたはCoTaskMemFreeが使用できません: {e}", file=sys.stderr)
    _known_folder_api_available = False


# --- Get Known Folder Path ---

def get_known_folder_path(folder_id: GUID) -> str | None:
    """
    FOLDERIDからフルパスを返す
    https://learn.microsoft.com/ja-jp/windows/win32/shell/knownfolderid
    Args:
        folder_id: GUID
    Returns:
        フルパス。取得できないならNone
    """
    if not _known_folder_api_available:
        return None # APIが利用できない

    path_ptr = c_wchar_p() # 戻値を格納するための文字列ポインタ
    result = shell32.SHGetKnownFolderPath(ctypes.byref(folder_id), 0, None, ctypes.byref(path_ptr))
    if result == 0: # S_OK
        path = path_ptr.value
        ole32.CoTaskMemFree(path_ptr) # COMが確保したメモリを開放
        return path
    else:
        print(f"Error calling SHGetKnownFolderPath: HRESULT={result:08X}", file=sys.stderr)
        return None


# --- COM Interface Definitions ---
# RECT
class POINT(Structure):
    _fields_ = [("x", c_int),
                ("y", c_int)]

class RECT(Structure):
    _fields_ = [("upperleft", POINT),
                ("lowerright", POINT)]

# IDesktopWallpaper Interface

class IDesktopWallpaper(comtypes.IUnknown):
    """
    IDesktopWallpaperのCOMインタフェース
    """
    _iid_ = comtypes.GUID('{B92B56A9-8B55-4E14-9A89-0199BBB6F93B}')

    @classmethod
    def CoCreateInstance(cls):
        """DesktopWallpaper COMオブジェクトの生成"""
        class_id = comtypes.GUID('{C2CF3110-460E-4fc1-B9D0-8A1C0C9CC4BD}')
        return comtypes.CoCreateInstance(class_id, interface=cls)

    _methods_ = [
        comtypes.COMMETHOD([], HRESULT, 'SetWallpaper', 
            (['in'], LPCWSTR, 'monitorID'),
            (['in'], LPCWSTR, 'wallpaper')),
        comtypes.COMMETHOD([], HRESULT, 'GetWallpaper',
            (['in'], LPCWSTR, 'monitorID'),
            (['out'], POINTER(LPWSTR), 'wallpaper')),
        comtypes.COMMETHOD([], HRESULT, 'GetMonitorDevicePathAt',
            (['in'], UINT, 'monitorIndex'),
            (['out'], POINTER(LPWSTR), 'monitorID')),
        comtypes.COMMETHOD([], HRESULT, 'GetMonitorDevicePathCount',
            (['out'], POINTER(UINT), 'count')),
        comtypes.COMMETHOD([], HRESULT, 'GetMonitorRECT',
            (['in'], LPCWSTR, 'monitorID'),
            (['out'], POINTER(RECT), 'displayRect')),
    ]

    def SetWallpaper(self, monitorId: str, wallpaper: str):
        """monitorIDに壁紙strをセット"""
        self.__com_SetWallpaper(LPCWSTR(monitorId), LPCWSTR(wallpaper))

    def GetWallpaper(self, monitorId: str) -> str:
        """monitorIDの壁紙を返す"""
        wallpaper_ptr = LPWSTR()
        self.__com_GetWallpaper(LPCWSTR(monitorId), pointer(wallpaper_ptr))
        return wallpaper_ptr.value

    def GetMonitorDevicePathAt(self, monitorIndex: int) -> str:
        """モニタのインデックス番号から一意のID(monitorID)を返す"""
        monitorId_ptr = LPWSTR()
        self.__com_GetMonitorDevicePathAt(UINT(monitorIndex), pointer(monitorId_ptr))
        return monitorId_ptr.value

    def GetMonitorDevicePathCount(self) -> int:
        """
        システムに記録されているモニタの数を返す
        接続中のモニタだけでなく過去に接続されたモニタなどを含むことに注意
        """
        count_ptr = UINT()
        self.__com_GetMonitorDevicePathCount(pointer(count_ptr))
        return count_ptr.value

    def GetMonitorRECT(self, monitorId: str) -> RECT | None:
        """
        monitorIDの表示矩形を返す
        monitorIDが接続されていなければNoneを返す
        """
        rect = RECT()
        try:
            self.__com_GetMonitorRECT(LPCWSTR(monitorId), pointer(rect))
            return rect
        except COMError as ce:
            return None

# Configuration File

def read_config(config_path: str) -> str | None:
    """config.iniから壁紙ディレクトリのパスを得る"""
    try:
        if not os.path.exists(config_path):
            return None # config.iniがない
        config = configparser.ConfigParser()
        config.read(config_path, encoding='utf-8')
        if CONFIG_SECTION in config and CONFIG_OPTION in config[CONFIG_SECTION]:
            path = config[CONFIG_SECTION][CONFIG_OPTION]
            if path and isinstance(path, str) and path.strip():
                if pathlib.Path(path).is_dir():
                    return path
                else:
                    print(f"'{path}'が存在しないかディレクトリではありません", file=sys.stderr)
                    return None
            else:
                 print(f"'{CONFIG_OPTION}'が設定されていません", file=sys.stderr)
                 return None 
        else:
            print(f"'[{CONFIG_SECTION}]'か'{CONFIG_OPTION}'が{config_path}に設定されていません", file=sys.stderr)
            return None
    except configparser.Error as e:
        print(f"'{config_path}'が読み取れない: {e}", file=sys.stderr)
        return None
    except Exception as e:
        print(f"'{config_path}'読み取り中にエラー: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return None

def write_config(config_path: str, wallpaper_path: str):
    """config.iniを作成する"""
    try:
        config = configparser.ConfigParser()
        # すでにあるなら読む
        if os.path.exists(config_path):
             config.read(config_path, encoding='utf-8')

        if CONFIG_SECTION not in config:
            config.add_section(CONFIG_SECTION)
        config[CONFIG_SECTION][CONFIG_OPTION] = wallpaper_path
        with open(config_path, 'w', encoding='utf-8') as configfile:
            config.write(configfile)
    except (IOError, configparser.Error) as e:
        print(f"'{config_path}'書き込み中にエラー: {e}", file=sys.stderr)
    except Exception as e:
        print(f"'{config_path}'作成に失敗: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)

# --- Main ---

def main(wallPath: str):
    """
    接続されているモニタに個別の壁紙をランダムに貼る
    """
    # Setup logging
    log_file_path = os.path.join(os.path.dirname(__file__), "wallpaper_setter_log.txt")
    log_fd = None
    try:
        log_fd = codecs.open(log_file_path, mode="w", encoding="utf-8")
        print(f"\n--- Log Start: {datetime.datetime.now()} ---", file=log_fd)
    except (IOError, ValueError) as e:
        print(f"'{log_file_path}'をオープンできないためstdoutを使用します: {e}", file=sys.stderr)
        log_fd = sys.stdout

    try:
        wallpaper_dir = pathlib.Path(wallPath)
        print(f"壁紙ディレクトリ: {wallPath}", file=log_fd)

        # --- 壁紙フォルダのファイルリストを得る ---
        wallpapers = [*map(str, wallpaper_dir.iterdir())]
        if not wallpapers:
            print(f"壁紙ディレクトリが空です: {wallPath}", file=log_fd)
            return

        # --- IDesktopWallpaerのCOMインタフェースを初期化 ---
        try:
            comtypes.CoInitialize()
            desktop_wallpaper = IDesktopWallpaper.CoCreateInstance()
        except (OSError, COMError) as e:
            print(f"IDesktopWallpaperインタフェースの初期化に失敗: {e}", file=log_fd)
            traceback.print_exc(file=log_fd)
            return

        monitor_set_count = 0
        try:
            monitor_count = desktop_wallpaper.GetMonitorDevicePathCount()
            print(f"システムには{monitor_count}台のモニタが登録されています", file=log_fd)
            if monitor_count == 0:
                print("システムに登録されたモニタがありません", file=log_fd)
                return

            for i in range(monitor_count):
                monitor_id = None
                try:
                    monitor_id = desktop_wallpaper.GetMonitorDevicePathAt(i)
                    monitor_rect = desktop_wallpaper.GetMonitorRECT(monitor_id)
                    if monitor_rect:
                        selected_wallpaper = random.choice(wallpapers)
                        desktop_wallpaper.SetWallpaper(monitor_id, selected_wallpaper)
                        print(f"{monitor_id}: {selected_wallpaper}", file=log_fd)
                    else:
                        print(f"モニタ {i} (ID: {monitor_id}) の表示領域が取得できません", file=log_fd)
                except COMError as ce:
                    pass
                except Exception as e:
                    pass

        except COMError as ce:
            print(f"c: {ce}", file=log_fd)
            traceback.print_exc(file=log_fd)
        except Exception as e:
            print(f"の表示領域が取得できません: {e}", file=log_fd)
            traceback.print_exc(file=log_fd)

    except Exception as e:
        print(f"何らかのエラーが発生: {e}", file=log_fd)
        traceback.print_exc(file=log_fd)

    finally:
        try:
            comtypes.CoUninitialize()
        except Exception as e:
            print(f"COM終了処理中にエラー発生: {e}", file=log_fd)

        if log_fd is not None and log_fd is not sys.stdout:
            try:
                print(f"--- Log End: {datetime.datetime.now()} ---\n", file=log_fd)
                log_fd.close()
            except IOError as e:
                print(f"ログファイルをクローズできない: {e}", file=sys.stderr)

# --- Entry Point ---

if __name__ == '__main__':
    import datetime

    script_dir = os.path.dirname(__file__)
    config_file_path = os.path.join(script_dir, CONFIG_FILE_NAME)

    wallpaper_path = read_config(config_file_path)
    # config.iniからパスが読み取れな場合は有効なconfig.iniを作成して終了する
    if not wallpaper_path:
        default_picture_path = get_known_folder_path(FOLDERID_Pictures)
        if not default_picture_path:
            default_picture_path = "c:/Users/"
        # config.iniの作成
        write_config(config_file_path, default_picture_path)
    else:
        main(wallpaper_path)

Discussion