1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[Python] Tkinterでマルチディスプレイ用の画像Viewerを作る

Last updated at Posted at 2025-12-17

目的

自分は、横長ディスプレイと縦長ディスプレイが1台ずつのPC環境で作業をしています。
画像を見ている時、横長の画像を横長で、縦長の画像を縦長で表示して欲しいと思っていました。
そんなわけで画像の形状を応じて適切なディスプレイに表示してくれるViewerを作る事にしました。
GUIは、余計なインストールがいらないという一点のみでTkinterを使う事にします。
WindowsのAPIを使ってディスプレイの解像度を取得していますので、Windows用のコードになります。
もっとも、使っているのはそこだけなので手動で設定するなりすれば、他のOSでも動くはずです。

一応、マルチスレッド対応版も作ってみました。
重たいアニメーション画像で無ければ個人的に満足できる出来になりました。
やはりTkinterが並列処理の足枷になってしまいましたが。

環境

py -VV
Python 3.14.2 (tags/v3.14.2:df79316, Dec  5 2025, 17:18:21) [MSC v.1944 64 bit (AMD64)]
py -m pip list
pillow             12.0.0
pyperclip          1.11.0

コード

WindowsApi.py
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
viewer.py
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]
command ディレクトリの指定
py viewer.py -i <path>

-i <path>で、表示したい画像が保存されているディレクトリを指定します。

command クリップボード
py viewer.py -c

とすると、クリップボードに保存されている文字列をディレクトリとして渡します。
(例えば、ディレクトリをクリックしてパスのコピーを押してから起動します。)

起動オプション

Options
py viewer.py -i <path> -r

サブディレクトリ内のファイルも表示したい場合は、-r(--recurese)を指定します。

Options
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を呼び出す関数は、下記になります。

WindowsApi.py
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")の情報も欲しいので同時に判定しておきます。

Viewer
  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)の大きさを決めます。
下記の様にする事でそれぞれのディスプレイ全体が描画領域になります。

Viewer
  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()で指定したディレクトリからファイルパス一覧を取得します。
再帰的にファイルを取得する設定の場合は、サブディレクトリからもファイルを取得します。

Viewer
  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に保存されているので、それを見て開くファイルを決めます。

Viewer
  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()では、ファイルを開いて描画に必要な情報を取得するとともに描画用のデータへ変換します。
具体的には、以下の様な処理を行います。

  1. ファイルを開く
  2. サイズを取得し、self.getSubWindowIndex()で描画領域を決定する(どちらのSubWindowで描画するか)
  3. アニメーション画像に対応できるように全てのフレーム情報をself.getAllFrames()で取得する

self.getAllFrames()では、以下を行います。

  1. resizeImage()で画像サイズを描画領域に合わせて変更する
  2. Tikinter用に画像をImageTk.PhotoImage()で変換する
  3. アニメーション画像に対応できるように全てのフレーム情報をimagesに格納する
  4. 全てのフレームの表示時間をdurationsに格納する

画像の拡大・縮小

画像を描画領域(SubWindow)に合わせて拡大・縮小します。
描画領域を最大限使用するために、縦横の短い方に合わせて拡大・縮小します。

global
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に描画指示を出します。

Viewer
  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()
  1. self.getFileData()で画像情報を取得する
  2. self.updateText()でViewerに文字を描画しつつ、その文字を取得する
  3. self.subWindows[n].checkImages()で描画に必要な情報をSubWindowに送る
  4. self.subWindows[n].liftTop()で描画領域を最前面に移動させる

self.getFileData()では、以下を行います。

  1. self.files[self.current]にアクセスして必要な情報が得られるならばそれを返します
  2. 必要な情報が保存されていない場合は、self.openImage()を用いて必要な画像情報を得ます
  3. 情報を保存するオプション(-r(--keepMemory))が設定されている場合は、保存します。

文字情報の更新

文字として表示したい情報は、全てViewer側が持っているのでViewer側で文字列を用意します。
rootのLabelに結びつけてあるStringVarを用意した文字列で更新します。
もし、文字情報を表示するように設定されているならば(self.isPrint=True)、SubWindow側にも文字列を渡します。

Viewer
  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側では、以下の関数で画像を表示しています。

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.sequenceself.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にします。

SubWindow
  def liftTop(self):
    self.attributes("-topmost", True)
    self.attributes("-topmost", False)

イベント処理

イベントの結びつけは、以下で設定しています。
どのWidgetにフォーカルが当たっていても操作出来るように、bind_all()で処理を結びつけています。

Viewer
  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)

イベントによって呼び出されるコールバックは以下になります。

Viewer
    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()は、描画領域に文字情報を描画するかを切り替えます。


並列処理追加版

改良して一部に並列処理を追加したバージョンも作ってみました。
全般的に処理が軽くなりました。
ただし、重たいアニメーションを読み込む時にアニメーションが止まってしまうのですが、それを解消する事は出来ませんでした。

functions.py
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,
  }
viewer.py
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(): 全ての画像ファイルのパスを取得する

functions.py
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
Viewer
  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(): 描画に必要なデータを取り出し、描画命令を出す

Viewer
  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() 画像ファイルを必要な形式へ変換する

fanctons.py
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依存の処理のようです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?