マルチディスプレイ対応壁紙スクリプトのお話
マルチディスプレイ環境で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