目的
自分は、横長ディスプレイと縦長ディスプレイが1台ずつのPC環境で作業をしています。
画像を見ている時、横長の画像を横長で、縦長の画像を縦長で表示して欲しいと思っていました。
そんなわけで画像の形状を応じて適切なディスプレイに表示してくれるViewerを作る事にしました。
GUIは、余計なインストールがいらないという一点のみでTkinterを使う事にします。
WindowsのAPIを使ってディスプレイの解像度を取得していますので、Windows用のコードになります。
もっとも、使っているのはそこだけなので手動で設定するなりすれば、他のOSでも動くはずです。
一応、マルチスレッド対応版も作ってみました。
重たいアニメーション画像で無ければ個人的に満足できる出来になりました。
やはりTkinterが並列処理の足枷になってしまいましたが。
環境
Python 3.14.2 (tags/v3.14.2:df79316, Dec 5 2025, 17:18:21) [MSC v.1944 64 bit (AMD64)]
pillow 12.0.0
pyperclip 1.11.0
コード
import ctypes as ct
from ctypes import wintypes as wt
User32 = ct.windll.User32
# typedef struct tagRECT {
# LONG left;
# LONG top;
# LONG right;
# LONG bottom;
# } RECT, *PRECT, *NPRECT, *LPRECT;
class RECT(ct.Structure):
_fields_ = [
("left", wt.LONG),
("top", wt.LONG),
("right", wt.LONG),
("bottom", wt.LONG),
]
def __repr__(self):
return f"({self.left}, {self.top}, {self.right}, {self.bottom})"
def getSize(self):
return (self.right - self.left, self.bottom - self.top)
# BOOL EnumDisplayMonitors(
# [in] HDC hdc,
# [in] LPCRECT lprcClip,
# [in] MONITORENUMPROC lpfnEnum,
# [in] LPARAM dwData
# );
EnumDisplayMonitors = User32.EnumDisplayMonitors
EnumDisplayMonitors.restype = wt.BOOL
EnumDisplayMonitors.argtypes = (
wt.HDC,
ct.POINTER(RECT),
ct.c_void_p,
wt.LPARAM,
)
def getDisplaysResolution():
resolutions = []
monitorEnumProc = ct.WINFUNCTYPE(
wt.BOOL,
wt.HMONITOR,
wt.HDC,
ct.POINTER(RECT), # RECT
wt.LPARAM,
)
def _monitorEnumProc(_HMONITOR, _HDC, LPRECT, _LPARAM):
resolutions.append(LPRECT.contents.getSize())
return True
res = EnumDisplayMonitors(None, None, monitorEnumProc(_monitorEnumProc), 0)
if res == 0:
raise ct.WinError()
return resolutions
import argparse
import pathlib
import re
import tkinter as tk
from tkinter import ttk
import pyperclip
import Utility as U
import WindowsApi as WinApi
from PIL import Image, ImageSequence, ImageTk
def toGeometry(width, height, left, top):
return f"{width}x{height}+{left}+{top}"
def fromGeometry(geometry):
m = re.match(r"(?P<width>\d+)x(?P<height>\d+)\+(?P<left>\d+)\+(?P<top>\d+)", geometry)
if m is not None:
return (int(m["width"]), int(m["height"]), int(m["left"]), int(m["top"]))
return (0, 0, 0, 0)
def resizeImage(image, width, height):
w, h = image.size
ratio = min(width / w, height / h)
size = (int(w * ratio), int(h * ratio))
return image.resize(size, Image.LANCZOS)
class SubWindow(tk.Toplevel):
def __init__(self, master=None, title="", geometry="0x0+0+0"):
super().__init__(master)
self.image = None
self.sequence = []
self.durations = []
self.text = ""
self.animationId = 0
self.geometryData = fromGeometry(geometry)
self.title(title)
self.geometry(geometry)
self.wm_overrideredirect(True)
self.canvas = tk.Canvas(self)
self.canvas.configure(width=self.geometryData[0], height=self.geometryData[1], bg="gray")
self.canvas.pack()
def checkImages(self, images, durations, text):
self.canvas.delete("all")
if len(images) == 1:
self.sequence = []
self.durations = []
self.drawImage(images[0])
self.drawText(text)
else:
self.sequence = images
self.durations = durations
self.animationId += 1
self.animation(0, self.animationId, text)
def drawImage(self, image):
self.image = image
self.canvas.create_image(self.geometryData[0] // 2, self.geometryData[1] // 2, image=self.image, anchor=tk.CENTER)
def animation(self, index, aid, text):
end = len(self.sequence)
if end == 0 or aid != self.animationId:
return
i = index if index < end else 0
self.image = self.sequence[i]
self.canvas.delete("all")
self.canvas.create_image(self.geometryData[0] // 2, self.geometryData[1] // 2, image=self.image, anchor=tk.CENTER)
self.drawText(text)
self.canvas.after(self.durations[i], self.animation, i + 1, aid, text)
def liftTop(self):
self.attributes("-topmost", True)
self.attributes("-topmost", False)
def drawText(self, text):
if text == "":
return
self.text = text
self.canvas.create_text(self.geometryData[0] // 2, 20, text=self.text, fill="red", font=("", 20), anchor=tk.CENTER)
class Viewer(tk.Frame):
Extensions = (
".jpg",
".webp",
".png",
".gif",
)
LandScape = "landscape"
Portrait = "portrait"
def __init__(self, master, directory, isRecurse, isKeepMemory):
super().__init__(master)
self.subWindows = []
self.resolutions = []
self.directory = directory
self.isRecurse = isRecurse
self.isKeepMemory = isKeepMemory
self.files = [] # {"path":, "image":, "orientation":, "originalSize"}
self.current = 0
self.end = 0
self.isPrint = False
self.master.title("Viewer")
self.master.resizable(True, False)
self.setLabel()
self.getResolutions()
self.master.geometry(f"{self.resolutions[0][0] // 2}x35+0+0")
self.createSubWindows()
self.setBinds()
self.callGetFiles()
self.drawImage()
self.pack()
self.liftTop()
def setLabel(self):
self.labelText = tk.StringVar(value=" ")
self.label = ttk.Label(
self,
textvariable=self.labelText,
anchor=tk.CENTER,
font=(None, 16),
width=256,
foreground="white",
background="black",
relief="groove",
padding=[5, 5, 5, 5],
)
self.label.pack()
def getResolutions(self):
for width, height in WinApi.getDisplaysResolution():
if width >= height:
self.resolutions.append((width, height, Viewer.LandScape))
else:
self.resolutions.append((width, height, Viewer.Portrait))
def liftTop(self):
self.master.attributes("-topmost", True)
self.master.attributes("-topmost", False)
self.focus_set()
def setBinds(self):
self.bind_all("<KeyPress-Right>", self.next)
self.bind_all("<KeyPress-Left>", self.previous)
self.bind_all("<KeyPress-Up>", self.listTopAll)
self.bind_all("<KeyPress-Down>", self.withDraw)
self.bind_all("<KeyPress-Escape>", self.destroyAll)
self.bind_all("<KeyPress-F12>", self.setPrint)
self.master.protocol("WM_DELETE_WINDOW", self.callDestroyAll)
def createSubWindows(self):
for i, (width, height, _) in enumerate(self.resolutions):
self.subWindows.append(
SubWindow(
self,
title=f"subWindow{i}",
geometry=toGeometry(
width,
height,
self.resolutions[i - 1][0] if i != 0 else 0,
0,
),
),
)
def destroyAll(self, _event):
for w in self.subWindows:
w.destroy()
self.master.destroy()
def callDestroyAll(self):
self.destroyAll(None)
def getFiles(self, path):
files = []
for file in path.iterdir():
if file.is_file() and file.suffix in Viewer.Extensions:
files.append({"path": file})
if file.is_dir() and self.isRecurse:
files += self.getFiles(file)
return files
def callGetFiles(self):
self.files = self.getFiles(self.directory)
self.end = len(self.files)
def updateText(self, data):
text = f"{self.current + 1:{len(str(self.end))}} / {self.end}: {data['path'].name} "
text += f"[{data['originalSize'][0]}, {data['originalSize'][1]}({data['images'][0].width()}, {data['images'][0].height()})]"
self.labelText.set(text)
# print("\r\x1b[1M" + text, end="")
if not self.isPrint:
return ""
return text
def getSubWindowIndex(self, orientation):
for i, (_, _, o) in enumerate(self.resolutions):
if orientation == o:
return i
return 0
def getOrientation(self, size):
return Viewer.LandScape if size[0] >= size[1] else Viewer.Portrait
def getAllFrames(self, image, width, height):
images = []
durations = []
for frame in ImageSequence.all_frames(image):
images.append(ImageTk.PhotoImage(resizeImage(frame, width, height)))
durations.append(frame.info.get("duration", 1000))
return images, durations
def openImage(self):
path = self.files[self.current]["path"]
image = Image.open(path)
size = image.size
index = self.getSubWindowIndex(self.getOrientation(size))
images, durations = self.getAllFrames(image, self.resolutions[index][0], self.resolutions[index][1])
return {
"path": path,
"images": images,
"durations": durations,
"originalSize": size,
"subWindow": index,
}
def getFileData(self):
if self.files[self.current].get("images", None) is not None:
return self.files[self.current]
data = self.openImage()
if self.isKeepMemory:
self.files[self.current] = data
return data
def drawImage(self):
data = self.getFileData()
n = data["subWindow"]
text = self.updateText(data)
self.subWindows[n].checkImages(data["images"], data["durations"], text)
self.subWindows[n].liftTop()
def listTopAll(self, _event):
for subWindow in self.subWindows:
subWindow.deiconify()
subWindow.liftTop()
def withDraw(self, _event):
for subWindow in self.subWindows:
subWindow.withdraw()
self.liftTop()
def next(self, _event):
if self.current < self.end - 1:
self.current += 1
else:
self.current = 0
self.drawImage()
def previous(self, _event):
if self.current > 0:
self.current -= 1
else:
self.current = self.end - 1
self.drawImage()
def setPrint(self, _event):
self.isPrint = not self.isPrint
def argumentParser():
parser = argparse.ArgumentParser()
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("-i", "--directory", dest="directory", type=pathlib.Path)
group.add_argument("-c", "--clipboard", action="store_const", const="<clipboard>", dest="directory")
parser.set_defaults(directory=".")
parser.add_argument("-r", "--recurse", action="store_true")
parser.add_argument("-k", "--keepMemory", action="store_true")
args = parser.parse_args()
if args.directory == "<clipboard>":
path = pyperclip.paste() # 稀に取得できない
path = path.strip('"')
args.directory = pathlib.Path(path)
return args
if __name__ == "__main__":
args = argumentParser()
root = tk.Tk()
viewer = Viewer(root, args.directory, args.recurse, args.keepMemory)
viewer.mainloop()
操作
実行コマンド
usage: viewer.py [-h] (-i DIRECTORY | -c) [-r] [-k]
py viewer.py -i <path>
-i <path>で、表示したい画像が保存されているディレクトリを指定します。
py viewer.py -c
とすると、クリップボードに保存されている文字列をディレクトリとして渡します。
(例えば、ディレクトリをクリックしてパスのコピーを押してから起動します。)
起動オプション
py viewer.py -i <path> -r
サブディレクトリ内のファイルも表示したい場合は、-r(--recurese)を指定します。
py viewer.py -i <path> -k
-k(--keepMemory)を指定すると開いた画像ファイルのデータをメモリに保持し続けます。
同じファイルを何度も表示し直す場合に、動作が高速になります。
ただし、開いた画像ファイルが多くなるほどメモリ消費量が増えるので注意してください。
キー割り当て
| Key | 機能 |
|---|---|
| left | 次の画像を表示させる |
| right | 前の画像を表示させる |
| up | 描画領域を最前面に移動させる |
| down | 描画領域を非表示にする |
| escape | viewerを終了させる |
| F12 | 描画領域にファイル名を表示するかを切り替える |
解説
Widgetの階層
root: tk.Tk
-- Viewer: tk.frame
-- label: ttk.Label
-- SubWindow1: tk.TopLevel
-- canvas: tk.Canvas
-- SubWindow2: tk.TopLevel
-- canvas: tk.Canvas
Viewer直下のLabelは、ファイル名などを表示するための物です。
SubWindow1とSubWindow2が画像を表示するための領域です。
ディスプレイは、2つでそれぞれが横長か縦長になっていると想定しています。
そのため、SubWindowは、横長と縦長の2つになると想定しています。
ディスプレイの解像度を取得する
Windowsで複数ディスプレイの解像度を取得するには、EnumDisplayMonitors()を使うのが良さそうです。
APIを呼び出す関数は、下記になります。
import ctypes as ct
from ctypes import wintypes as wt
User32 = ct.windll.User32
# typedef struct tagRECT {
# LONG left;
# LONG top;
# LONG right;
# LONG bottom;
# } RECT, *PRECT, *NPRECT, *LPRECT;
class RECT(ct.Structure):
_fields_ = [
("left", wt.LONG),
("top", wt.LONG),
("right", wt.LONG),
("bottom", wt.LONG),
]
def __repr__(self):
return f"({self.left}, {self.top}, {self.right}, {self.bottom})"
def getSize(self):
return (self.right - self.left, self.bottom - self.top)
# BOOL EnumDisplayMonitors(
# [in] HDC hdc,
# [in] LPCRECT lprcClip,
# [in] MONITORENUMPROC lpfnEnum,
# [in] LPARAM dwData
# );
EnumDisplayMonitors = User32.EnumDisplayMonitors
EnumDisplayMonitors.restype = wt.BOOL
EnumDisplayMonitors.argtypes = (
wt.HDC,
ct.POINTER(RECT),
ct.c_void_p,
wt.LPARAM,
)
def getDisplaysResolution():
resolutions = []
monitorEnumProc = ct.WINFUNCTYPE(
wt.BOOL,
wt.HMONITOR,
wt.HDC,
ct.POINTER(RECT), # RECT
wt.LPARAM,
)
def _monitorEnumProc(_HMONITOR, _HDC, LPRECT, _LPARAM):
resolutions.append(LPRECT.contents.getSize())
return True
res = EnumDisplayMonitors(None, None, monitorEnumProc(_monitorEnumProc), 0)
if res == 0:
raise ct.WinError()
return resolutions
実際には、これを以下のように呼び出して解像度を取得します。
横長("landscape")か縦長("portrait")の情報も欲しいので同時に判定しておきます。
def getResolutions(self):
for width, height in U.getDisplaysResolution():
if width >= height:
self.resolutions.append((width, height, Viewer.LandScape))
else:
self.resolutions.append((width, height, Viewer.Portrait))
結果は、
[(1920, 1080, 'landscape'), (1080, 1920, 'portrait')]
のような配列となります。
取得した解像度を元に描画領域(SubWindow)の大きさを決めます。
下記の様にする事でそれぞれのディスプレイ全体が描画領域になります。
def createSubWindows(self):
for i, (width, height, _) in enumerate(self.resolutions):
self.subWindows.append(
SubWindow(
self,
title=f"subWindow{i}",
geometry=toGeometry(
width,
height,
self.resolutions[i - 1][0] if i != 0 else 0,
0,
),
),
)
画像の処理
始めにself.callGetFiles()で指定したディレクトリからファイルパス一覧を取得します。
再帰的にファイルを取得する設定の場合は、サブディレクトリからもファイルを取得します。
def getFiles(self, path):
files = []
for file in path.iterdir():
if file.is_file() and file.suffix in Viewer.Extensions:
files.append({"path": file})
if file.is_dir() and self.isRecurse:
files += self.getFiles(file)
return files
def callGetFiles(self):
self.files = self.getFiles(self.directory)
self.end = len(self.files)
次に、self.openImage()で描画するためにファイルを開きます。
現在開きたいファイルのインデックスは、self.currentに保存されているので、それを見て開くファイルを決めます。
def getSubWindowIndex(self, orientation):
for i, (_, _, o) in enumerate(self.resolutions):
if orientation == o:
return i
return 0
def getOrientation(self, size):
return Viewer.LandScape if size[0] >= size[1] else Viewer.Portrait
def getAllFrames(self, image, width, height):
images = []
durations = []
for frame in ImageSequence.all_frames(image):
images.append(ImageTk.PhotoImage(resizeImage(frame, width, height)))
durations.append(frame.info.get("duration", 1000))
return images, durations
def openImage(self):
path = self.files[self.current]["path"]
image = Image.open(path)
size = image.size
index = self.getSubWindowIndex(self.getOrientation(size))
images, durations = self.getAllFrames(image, self.resolutions[index][0], self.resolutions[index][1])
return {
"path": path,
"images": images,
"durations": durations,
"originalSize": size,
"subWindow": index,
}
self.openImage()では、ファイルを開いて描画に必要な情報を取得するとともに描画用のデータへ変換します。
具体的には、以下の様な処理を行います。
- ファイルを開く
- サイズを取得し、
self.getSubWindowIndex()で描画領域を決定する(どちらのSubWindowで描画するか) - アニメーション画像に対応できるように全てのフレーム情報を
self.getAllFrames()で取得する
self.getAllFrames()では、以下を行います。
-
resizeImage()で画像サイズを描画領域に合わせて変更する - Tikinter用に画像を
ImageTk.PhotoImage()で変換する - アニメーション画像に対応できるように全てのフレーム情報を
imagesに格納する - 全てのフレームの表示時間を
durationsに格納する
画像の拡大・縮小
画像を描画領域(SubWindow)に合わせて拡大・縮小します。
描画領域を最大限使用するために、縦横の短い方に合わせて拡大・縮小します。
def resizeImage(image, width, height):
w, h = image.size
ratio = min(width / w, height / h)
size = (int(w * ratio), int(h * ratio))
return image.resize(size, Image.LANCZOS)
画像表示処理
Viewer(root window)側では、以下でSubWindowに描画指示を出します。
def getFileData(self):
if self.files[self.current].get("images", None) is not None:
return self.files[self.current]
data = self.openImage()
if self.isKeepMemory:
self.files[self.current] = data
return data
def drawImage(self):
data = self.getFileData()
n = data["subWindow"]
text = self.updateText(data)
self.subWindows[n].checkImages(data["images"], data["durations"], text)
self.subWindows[n].liftTop()
-
self.getFileData()で画像情報を取得する -
self.updateText()でViewerに文字を描画しつつ、その文字を取得する -
self.subWindows[n].checkImages()で描画に必要な情報をSubWindowに送る -
self.subWindows[n].liftTop()で描画領域を最前面に移動させる
self.getFileData()では、以下を行います。
-
self.files[self.current]にアクセスして必要な情報が得られるならばそれを返します - 必要な情報が保存されていない場合は、
self.openImage()を用いて必要な画像情報を得ます - 情報を保存するオプション(
-r(--keepMemory))が設定されている場合は、保存します。
文字情報の更新
文字として表示したい情報は、全てViewer側が持っているのでViewer側で文字列を用意します。
rootのLabelに結びつけてあるStringVarを用意した文字列で更新します。
もし、文字情報を表示するように設定されているならば(self.isPrint=True)、SubWindow側にも文字列を渡します。
def setLabel(self):
self.labelText = tk.StringVar(value=" ")
self.label = ttk.Label(
self,
textvariable=self.labelText,
anchor=tk.CENTER,
font=(None, 16),
width=256,
foreground="white",
background="black",
relief="groove",
padding=[5, 5, 5, 5],
)
self.label.pack()
def updateText(self, data):
text = f"{self.current + 1:{len(str(self.end))}} / {self.end}: {data['path'].name} "
text += f"[{data['originalSize'][0]}, {data['originalSize'][1]}({data['images'][0].width()}, {data['images'][0].height()})]"
self.labelText.set(text)
# print("\r\x1b[1M" + text, end="")
if not self.isPrint:
return ""
return text
SubWindow側での描画処理
SubWindow側では、以下の関数で画像を表示しています。
def checkImages(self, images, durations, text):
self.canvas.delete("all")
if len(images) == 1:
self.sequence = []
self.durations = []
self.drawImage(images[0])
self.drawText(text)
else:
self.sequence = images
self.durations = durations
self.animationId += 1
self.animation(0, self.animationId, text)
def drawImage(self, image):
self.image = image
self.canvas.create_image(self.geometryData[0] // 2, self.geometryData[1] // 2, image=self.image, anchor=tk.CENTER)
def animation(self, index, aid, text):
end = len(self.sequence)
if end == 0 or aid != self.animationId:
return
i = index if index < end else 0
self.image = self.sequence[i]
self.canvas.delete("all")
self.canvas.create_image(self.geometryData[0] // 2, self.geometryData[1] // 2, image=self.image, anchor=tk.CENTER)
self.drawText(text)
self.canvas.after(self.durations[i], self.animation, i + 1, aid, text)
def liftTop(self):
self.attributes("-topmost", True)
self.attributes("-topmost", False)
def drawText(self, text):
if text == "":
return
self.text = text
self.canvas.create_text(self.geometryData[0] // 2, 20, text=self.text, fill="red", font=("", 20), anchor=tk.CENTER)
画像の表示
self.checkImages()でViewer側からのデータを受け取ります。
送られたデータを見て画像として表示するか、アニメーションとして表示するか決めます。
self.clearCanvas()でCanvasをリセットしています。
この処理は、情報を保存するオプション(-r(--keepMemory))が設定されている時に、古い画像が残り続けるのを阻止するのに必要です。
つまり、ImageTk.PhotoImage()のインスタンスが破棄されるようならば、Canvasのリセットは不要となります。
送られた画像(images)が1枚の場合は、self.drawImage()とself.drawText()で画像と文字を表示します。
その時に、self.sequenceとself.durationsをリセットします。
リセットするのは、以前に表示させたアニメーションがある場合に、それの描画を停止させる為です。
self.drawImage()は、特別な事を行っていないのですが、しばらくの間、画像が表示されずに困ってしまいました。
作成段階では、以下のように画像データをローカル変数に保存していました。
def _setImage(self, image):
image = ImageTk.PhotoImage(image)
self.canvas.create_image(self.info[0] // 2, self.info[1] // 2, image=image, anchor=tk.CENTER)
ローカル変数に保存していると、処理のタイミングやプログラムの構造によっては、画面が更新される前にローカル変数が削除されてしまいます。
そのため、画像が表示されないようです。
これを避ける単純な方法は、クラスのメンバ変数などに保存する事です。
アニメーションの表示
送られた画像(images)が複数枚の場合は、self.animation()でアニメーション表示させます。
canvas.after()を使ってself.sequenceの画像をself.durations時間だけ無限ループで表示させる単純なものです。
無限ループの終了条件は、
-
self.sequenceが空になる == 単一画像を表示する -
self.animationIdが変化した == 新しいアニメーションを表示する
になります。
canvas.create_image()の前に、canvas.delete()を呼んでいるのは、このようにしないと処理が徐々に重くなってしまうからです。
描画領域を最前面に移動させる
画像を更新したら、以下の関数で描画領域を最前面に移動させます。
"-topmost"をTrueし続けると他のアプリケーションなどが操作不能になるのでTrueにしてからFalseにします。
def liftTop(self):
self.attributes("-topmost", True)
self.attributes("-topmost", False)
イベント処理
イベントの結びつけは、以下で設定しています。
どのWidgetにフォーカルが当たっていても操作出来るように、bind_all()で処理を結びつけています。
def setBinds(self):
self.bind_all("<KeyPress-Right>", self.next)
self.bind_all("<KeyPress-Left>", self.previous)
self.bind_all("<KeyPress-Up>", self.listTopAll)
self.bind_all("<KeyPress-Down>", self.withDraw)
self.bind_all("<KeyPress-Escape>", self.destroyAll)
self.bind_all("<KeyPress-F12>", self.setPrint)
self.master.protocol("WM_DELETE_WINDOW", self.callDestroyAll)
イベントによって呼び出されるコールバックは以下になります。
def destroyAll(self, _event):
for w in self.subWindows:
w.destroy()
self.master.destroy()
def callDestroyAll(self):
self.destroyAll(None)
def listTopAll(self, _event):
for subWindow in self.subWindows:
subWindow.deiconify()
subWindow.liftTop()
def withDraw(self, _event):
for subWindow in self.subWindows:
subWindow.withdraw()
self.liftTop()
def next(self, _event):
if self.current < self.end - 1:
self.current += 1
else:
self.current = 0
self.drawImage()
def previous(self, _event):
if self.current > 0:
self.current -= 1
else:
self.current = self.end - 1
self.drawImage()
def setPrint(self, _event):
self.isPrint = not self.isPrint
destroyAll()で全てのWidgetを破棄します。
listTopAll()で描画領域(SubWindow)を最前面に移動させます。
最小化などがされている場合は、それを解除します。
withDraw()で描画領域(SubWindow)を非表示にします。
全て非表示にしてしまうと操作出来なくなってしまうので、Root Windowを最前面に移動させます。
next()とprevious()で表示する画像を切り替えます。
循環するように処理しています。
setPrint()は、描画領域に文字情報を描画するかを切り替えます。
並列処理追加版
改良して一部に並列処理を追加したバージョンも作ってみました。
全般的に処理が軽くなりました。
ただし、重たいアニメーションを読み込む時にアニメーションが止まってしまうのですが、それを解消する事は出来ませんでした。
import concurrent.futures as cf
import functools
import operator
import re
from collections import deque
from PIL import Image, ImageSequence, ImageTk
LandScape = "landscape"
Portrait = "portrait"
def toGeometry(width, height, left, top):
return f"{width}x{height}+{left}+{top}"
def fromGeometry(geometry):
m = re.match(r"(?P<width>\d+)x(?P<height>\d+)\+(?P<left>\d+)\+(?P<top>\d+)", geometry)
if m is not None:
return (int(m["width"]), int(m["height"]), int(m["left"]), int(m["top"]))
return (0, 0, 0, 0)
def getFiles(path, isRecurse, extensions=None):
files = deque(path.iterdir())
result = []
while len(files) > 0:
file = files.popleft()
if file.is_file() and (extensions is None or file.suffix in extensions):
result.append({"path": file})
elif file.is_dir() and isRecurse:
files.extend(file.iterdir())
result.sort(key=operator.itemgetter("path"))
return result
def resizeImage(image, width, height):
w, h = image.size
ratio = min(width / w, height / h)
size = (int(w * ratio), int(h * ratio))
return image.resize(size, Image.LANCZOS)
def getFrame(frame, width, height):
return ImageTk.PhotoImage(resizeImage(frame, width, height)), frame.info.get("duration", 1000)
def getAllFrames(image, width, height):
images = []
durations = []
with cf.ThreadPoolExecutor() as ex: # OK
func = functools.partial(getFrame, width=width, height=height)
results = ex.map(func, ImageSequence.all_frames(image), timeout=60)
for img, duration in results:
images.append(img)
durations.append(duration)
return images, durations
def getOrientation(size):
return LandScape if size[0] >= size[1] else Portrait
def getSubWindowIndex(resolutions, orientation):
for i, (_, _, o) in enumerate(resolutions):
if orientation == o:
return i
return 0
def openImage(path, resolutions):
image = Image.open(path)
size = image.size
index = getSubWindowIndex(resolutions, getOrientation(size))
images, durations = getAllFrames(image, resolutions[index][0], resolutions[index][1])
return {
"path": path,
"images": images,
"durations": durations,
"originalSize": size,
"subWindow": index,
}
import argparse
import concurrent.futures as cf
import pathlib
import tkinter as tk
from threading import Lock
from tkinter import ttk
import pyperclip
import WindowsApi as WinApi
import functions as f
ThreadExecutor = cf.ThreadPoolExecutor()
class SubWindow(tk.Toplevel):
def __init__(self, master=None, title="", geometry="0x0+0+0"):
super().__init__(master)
self.image = None
self.sequence = []
self.durations = []
self.text = ""
self.animationId = 0
self.geometryData = f.fromGeometry(geometry)
self.title(title)
self.geometry(geometry)
self.wm_overrideredirect(True)
self.canvas = tk.Canvas(self)
self.canvas.configure(width=self.geometryData[0], height=self.geometryData[1], bg="gray")
self.canvas.pack()
def checkImages(self, images, durations, text):
if len(images) == 1:
self.sequence = []
self.durations = []
self.drawImage(images[0])
self.drawText(text)
else:
self.sequence = images
self.durations = durations
self.animationId += 1
self.animation(0, self.animationId, text)
def drawImage(self, image):
self.canvas.delete("all")
self.image = image
self.canvas.create_image(self.geometryData[0] // 2, self.geometryData[1] // 2, image=self.image, anchor=tk.CENTER)
def animation(self, index, aid, text):
end = len(self.sequence)
if end == 0 or aid != self.animationId:
return
i = index if index < end else 0
self.image = self.sequence[i]
self.canvas.delete("all")
self.canvas.create_image(self.geometryData[0] // 2, self.geometryData[1] // 2, image=self.image, anchor=tk.CENTER)
self.drawText(text)
self.canvas.after(self.durations[i], self.animation, i + 1, aid, text)
def liftTop(self):
self.attributes("-topmost", True)
self.attributes("-topmost", False)
def drawText(self, text):
if text == "":
return
self.text = text
self.canvas.create_text(self.geometryData[0] // 2, 20, text=self.text, fill="red", font=("", 20), anchor=tk.CENTER)
class Viewer(tk.Frame):
Extensions = (
".jpg",
".webp",
".png",
".gif",
)
EventFinishGetFiles = "<<FinishGetFiles>>"
def __init__(self, master, directory, isRecurse, isKeepMemory):
super().__init__(master)
self.subWindows = []
self.resolutions = []
self.directory = directory
self.isRecurse = isRecurse
self.isKeepMemory = isKeepMemory
self.files = [] # {"path":, "images":, "durations":, "originalSize":, "subWindow":}
self.current = -1
self.end = 0
self.isPrint = False
self.lock = Lock()
self.master.title("Viewer")
self.master.resizable(True, False)
self.setLabel()
self.getResolutions()
self.master.geometry(f"{self.resolutions[0][0] // 2}x35+0+0")
self.createSubWindows()
self.setBinds()
self.getFiles()
self.pack()
self.liftTop()
def setLabel(self):
self.labelText = tk.StringVar(value=" ")
self.label = ttk.Label(
self,
textvariable=self.labelText,
anchor=tk.CENTER,
font=(None, 16),
width=256,
foreground="white",
background="black",
relief="groove",
padding=[5, 5, 5, 5],
)
self.label.pack()
def getResolutions(self):
for width, height in WinApi.getDisplaysResolution():
if width >= height:
self.resolutions.append((width, height, f.LandScape))
else:
self.resolutions.append((width, height, f.Portrait))
def liftTop(self):
self.master.attributes("-topmost", True)
self.master.attributes("-topmost", False)
self.focus_set()
def setBinds(self):
self.bind_all("<KeyPress-Right>", self.next)
self.bind_all(Viewer.EventFinishGetFiles, self.next)
self.bind_all("<KeyPress-Left>", self.previous)
self.bind_all("<KeyPress-Up>", self.listTopAll)
self.bind_all("<KeyPress-Down>", self.withDraw)
self.bind_all("<KeyPress-Escape>", self.destroyAll)
self.bind_all("<KeyPress-F12>", self.setPrint)
self.master.protocol("WM_DELETE_WINDOW", self.callDestroyAll)
def createSubWindows(self):
for i, (width, height, _) in enumerate(self.resolutions):
self.subWindows.append(
SubWindow(
self,
title=f"subWindow{i}",
geometry=f.toGeometry(
width,
height,
self.resolutions[i - 1][0] if i != 0 else 0,
0,
),
),
)
def destroyAll(self, _event):
for w in self.subWindows:
w.destroy()
self.master.destroy()
def callDestroyAll(self):
self.destroyAll(None)
def setFiles(self, future):
self.files = future.result()
self.end = len(self.files)
self.labelText.set(f"{self.directory}: {self.end}")
self.event_generate(Viewer.EventFinishGetFiles)
def getFiles(self):
ft = ThreadExecutor.submit(f.getFiles, self.directory, self.isRecurse, Viewer.Extensions)
ft.add_done_callback(self.setFiles)
def updateText(self, data):
text = f"{self.current + 1:{len(str(self.end))}} / {self.end}: {data['path'].name} "
text += f"[{data['originalSize'][0]}, {data['originalSize'][1]}({data['images'][0].width()}, {data['images'][0].height()})]"
self.labelText.set(text)
# print("\r\x1b[1M" + text, end="")
if not self.isPrint:
return ""
return text
def getFileData(self, i):
data = self.files[i]
if self.files[i].get("images", None) is None:
data = f.openImage(self.files[i]["path"], self.resolutions)
if self.isKeepMemory:
self.lock.acquire()
self.files[i] = data
self.lock.release()
self.drawImage(data)
def drawImage(self, data):
n = data["subWindow"]
text = self.updateText(data)
self.subWindows[n].checkImages(data["images"], data["durations"], text)
self.subWindows[n].liftTop()
def listTopAll(self, _event):
for subWindow in self.subWindows:
subWindow.deiconify()
subWindow.liftTop()
def withDraw(self, _event):
for subWindow in self.subWindows:
subWindow.withdraw()
self.liftTop()
def next(self, _event):
if self.end < 1:
return
if self.current < self.end - 1:
self.current += 1
else:
self.current = 0
ThreadExecutor.submit(self.getFileData, self.current)
def previous(self, _event):
if self.end < 1:
return
if self.current > 0:
self.current -= 1
else:
self.current = self.end - 1
ThreadExecutor.submit(self.getFileData, self.current)
def setPrint(self, _event):
self.isPrint = not self.isPrint
def argumentParser():
parser = argparse.ArgumentParser()
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("-i", "--directory", dest="directory", type=pathlib.Path)
group.add_argument("-c", "--clipboard", action="store_const", const="<clipboard>", dest="directory")
parser.set_defaults(directory=".")
parser.add_argument("-r", "--recurse", action="store_true")
parser.add_argument("-k", "--keepMemory", action="store_true")
args = parser.parse_args()
if args.directory == "<clipboard>":
path = pyperclip.paste() # 稀に取得できない?
path = path.strip('"')
args.directory = pathlib.Path(path)
return args
if __name__ == "__main__":
args = argumentParser()
root = tk.Tk()
viewer = Viewer(root, args.directory, args.recurse, args.keepMemory)
viewer.mainloop()
並列化された処理
ProcessPoolExecutorを使おうと思って関数を外部モジュールで定義するようにしました。
もっとも、ProcessPoolExecutorを上手く使えなかったので意味が無かったです。
やはり、tkinterとの相性、特にtkinter.PhotoImage(ImageTk.PhotoImage)との相性が悪いです。
並列処理は、全てThreadPoolExecutorで行っています。
以下の関数がThreadPoolExecutorで実行されます。
-
getFiles(): 全ての画像ファイルのパスを取得する -
getFileData(): 描画に必要なデータを取り出し、描画命令を出す -
getFrame(): 画像ファイルを必要な形式へ変換する
getFiles()とgetFileData()をスレッドで処理しているのは、Viewerが応答しない時間を無くすためです。
もっとも、getFiles()が終了しないとViewerは何も出来ないのですが。
getFrame()は、一番時間が掛かる処理であるgetAllFrames()からThreadPoolExecutorを通じて呼ばれます。
この処理は、スレッド処理で十分早くなります。
getFiles(): 全ての画像ファイルのパスを取得する
def getFiles(path, isRecurse, extensions=None):
files = deque(path.iterdir())
result = []
while len(files) > 0:
file = files.popleft()
if file.is_file() and (extensions is None or file.suffix in extensions):
result.append({"path": file})
elif file.is_dir() and isRecurse:
files.extend(file.iterdir())
result.sort(key=operator.itemgetter("path"))
return result
def setFiles(self, future):
self.files = future.result()
self.end = len(self.files)
self.labelText.set(f"{self.directory}: {self.end}")
self.event_generate(Viewer.EventFinishGetFiles)
def getFiles(self):
ft = ThreadExecutor.submit(f.getFiles, self.directory, self.isRecurse, Viewer.Extensions)
ft.add_done_callback(self.setFiles)
ThreadPoolExecutor.submit()でfunctions.getFiles()を呼んでいます。
getFiles()は、実行速度を上げるために非再帰バージョンとしました。
この処理でViewerが応答できなくなる時間を無くすために、future.result()を直接呼ばずにfutre.add_done_callback()で後続処理をしています。
こうする事で処理を投げっぱなしに出来ます。
getFileData(): 描画に必要なデータを取り出し、描画命令を出す
def getFileData(self, i):
data = self.files[i]
if self.files[i].get("images", None) is None:
data = f.openImage(self.files[i]["path"], self.resolutions)
if self.isKeepMemory:
self.lock.acquire()
self.files[i] = data
self.lock.release()
self.drawImage(data)
def next(self, _event):
if self.end < 1:
return
if self.current < self.end - 1:
self.current += 1
else:
self.current = 0
ThreadExecutor.submit(self.getFileData, self.current)
この処理も、Viewerが応答できなくなる時間を無くすためにスレッドで実行しています。
getFileData()では、以前はself.files[self.current]でデータにアクセスしていましたが、引数として与えられたiでアクセスするようにしています。(self.files[i])
これは、スレッド外でself.currentを変更される恐れがあるからです。
また、可能性は低いですが、self.files[i]を同時に更新される恐れがあるためにThreding.Lockでロックしてから更新しています。
getFrame() 画像ファイルを必要な形式へ変換する
def getFrame(frame, width, height):
return ImageTk.PhotoImage(resizeImage(frame, width, height)), frame.info.get("duration", 1000)
def getAllFrames(image, width, height):
images = []
durations = []
with cf.ThreadPoolExecutor() as ex: # OK
func = functools.partial(getFrame, width=width, height=height)
results = ex.map(func, ImageSequence.all_frames(image), timeout=60)
for img, duration in results:
images.append(img)
durations.append(duration)
return images, durations
getAllFrames()で、getFrame()をThreadPoolExecutor.map()を使い処理しています。
これは、時間が掛かるImageTk.PhotoImage()を、並列に処理するためです。
getFrame()は、ImageTk.PhotoImage()を使い画像を必要な形式へ変換します。
ImageTk.PhotoImage()は、プログラムの構造に次第でよっては、ProcessPoolExecutorで処理できない事があります。
その理由と対処が分かりませんでしたのでThreadPoolExecutorで処理しています。
もっとも、実行速度は、ThreadPoolExecutorの方が早くなります。
ソースを見るなりするとImageTk.PhotoImage()は、外部モジュールの処理を呼び出すI/O依存の処理のようです。