開発者用拡張機能を公開するサイト

pythonで画像ビュアーを作る

これはWindows10を使い始めた頃から思っていることではありますが、
デフォルトの画像ビュアーである「フォト」ってすごく使いにくいですよね。

理由は様々あるのですが、私は対処として「フォトビューアー」を有効化して使っていました。
しかしてWindows11に乗り換えた今、フォトビューアーがいつまで使えるかわかりません。

一応レジストリを変更して無理やり起動することはできますが、
WindowsUpdateでいつ消えるかもわかりません。

さてどうするか。
フォトビューアーはフリーツールを探せば色々あります。
ただ個人的には搭載されている機能など画像を確認できればそれでいいのです。
編集、印刷といった機能は他のツールを使います。

であるならば。
プログラマであるなら作ってしまってもいいではないか。

という研究の意も込めて自作することにしました。

使用言語はpython。
採用理由:visual studio使えばC#で作れるけどpythonで作ってもいいじゃない。

まずコード全文

機密もなにもないので全文公開します。
330行あります。

なお精査しておりませんのでグチャグチャだったりクドイ記述があります。
またご利用は自己責任で。

from PIL import Image, ImageTk, ImageCms, ImageDraw, ImageFont
import tkinter
import io

import os,sys,glob,mimetypes


class pyImageViewer:

	def __init__(self):
		sep_dir = sys.argv[1].split(os.sep)
		
		self.img_name = sep_dir.pop()
		sep_dir = '{}{}'.format((os.sep).join(sep_dir), os.sep)
		
		file_list = glob.glob('{}*.*'.format(sep_dir))
		self.file_list2 = []
		
		exte_list = ['.raw']
		for img_path in file_list:
			if (
				(mimetypes.guess_type(img_path)[0] is not None) and
				('image' in mimetypes.guess_type(img_path)[0]) and
				((os.path.splitext(img_path)[1].lower() in exte_list) == False)
			):
				self.file_list2.append(img_path)
			
		self.file_len = len(self.file_list2)
		self.file_index = self.file_list2.index(sys.argv[1])
		
		#=======================
		
		self.size_list = {'mw':960,'mh':540,'iw':0,'ih':0,'sw':1,'sh':1,'f':10,'z':0,'fit':True,'fit_z':0,}
		self.size_list['nw'] = self.size_list['mw']
		self.size_list['nh'] = self.size_list['mh'] - int(self.size_list['f'] * 2.5)
		self.size_list['pw'] = int(self.size_list['nw'] / 2)
		self.size_list['ph'] = int(self.size_list['nh'] / 2)
		
		self.def_image = None
		self.set_image = None
		self.canvas_image = None
		
		self.ImageViewer= tkinter.Tk()
		
		self.ImageViewer.title("PythonImageViewer")
		self.ImageViewer.geometry("{}x{}".format(self.size_list['mw'],self.size_list['mh']))
		
		self.ImageViewer.bind("<KeyPress>", self.key_down)
		self.ImageViewer.bind("<Configure>", self.on_resize)
		self.ImageViewer.bind("<ButtonPress>", self.mouse_button)
		self.ImageViewer.bind("<MouseWheel>", self.mouse_wheel)
		self.ImageViewer.bind("<Motion>", self.mouse_move)
		
		
		#=======================
		
		self.canvas = tkinter.Canvas(self.ImageViewer, width=self.size_list['nw'], height=self.size_list['nh'])
		self.canvas.pack()
		
		self.info_label = tkinter.Label(font=("System",self.size_list['f']),anchor="w")
		self.info_label.pack(fill="x",ipadx=2)
		
		self.image_set()
		
		self.ImageViewer.mainloop()
	
	
	#=======================
	#キーボード
	def key_down(self, event):
		if event.keysym == 'Right':
			self.size_list['fit'] = True
			self.file_index_change(0)
		elif event.keysym == 'Left':
			self.size_list['fit'] = True
			self.file_index_change(1)
		else:
			return False
		
		self.size_list['fit_z'] = 0
		self.image_center()
		self.image_set()
	
	#=======================
	#マウス
	def mouse_button(self, event):
		if event.num == 5:
			self.size_list['fit'] = True
			self.file_index_change(0)
		
		elif event.num == 4:
			self.size_list['fit'] = True
			self.file_index_change(1)
		
		elif event.num == 1:
			if self.size_list['fit_z'] == self.size_list['z']:
				self.size_list['fit'] = False
				self.size_list['z'] = 100
			else:
				self.size_list['fit'] = True
				self.size_list['z'] = self.size_list['fit_z']
		else:
			return False
		
		self.size_list['fit_z'] = 0
		self.image_center()
		self.image_set()
	
	#=======================
	#マウスホイール
	def mouse_wheel(self, event):
		zoom_set = 10
		self.size_list['fit'] = False
		if 0 < event.delta:
			#上回転
			if self.size_list['z'] == 100:
				return False
			
		else:
			if self.size_list['z'] == 10:
				return False
			
			zoom_set = -10
		
		self.size_list['z'] = (int(self.size_list['z'] / 10) * 10) + zoom_set
		
		if self.size_list['z'] < 10:
			self.size_list['z'] = 10
		elif 100 < self.size_list['z']:
			self.size_list['z'] = 100
		
		if self.size_list['sw'] <= self.size_list['nw'] and self.size_list['sh'] <= self.size_list['nh']:
			self.image_center()
		self.image_set()
	
	#=======================
	#マウス移動
	def mouse_move(self, event):
		ssw = self.size_list['iw'] * (self.size_list['z'] / 100)
		ssh = self.size_list['ih'] * (self.size_list['z'] / 100)
		if self.size_list['fit'] == True or (ssw < self.size_list['nw'] and ssh < self.size_list['nh']):
			return False
		
		mw = self.size_list['nw'] / 2
		mh = self.size_list['nh'] / 2
		
		#x=================
		if self.size_list['nw'] < ssw:
			ex = event.x - mw
			if ex == 0:
				pos_x = self.size_list['nw'] / 2
			else:
				pos_x = mw - (ssw - self.size_list['nw']) * (ex / self.size_list['nw']) * 1.3
		else:
			pos_x = self.size_list['nw'] / 2
		
		#y=================
		if self.size_list['nh'] < ssh:
			ey = event.y - mh
			if ey == 0:
				pos_y = self.size_list['nh'] / 2
			else:
				pos_y = mh - (ssh - self.size_list['nh']) * (ey / self.size_list['nh']) * 1.15
		else:
			pos_y = self.size_list['nh'] / 2
		
		self.canvas.coords(self.canvas_image, int(pos_x), int(pos_y))
	
	#=======================
	#画像番号 変更
	def file_index_change(self, mode):
		if mode == 0:
			self.file_index = self.file_index + 1
			if self.file_len <= self.file_index:
				self.file_index = 0
		else:
			self.file_index = self.file_index - 1
			if self.file_index < 0:
				self.file_index = self.file_len - 1
	
	#=======================
	#左下
	def info_set(self):
		self.info_label["text"] = "No.{}/{}「{}」「width:{} height:{} ({}%)」".format(
			(self.file_index + 1),
			self.file_len,
			self.img_name,
			self.size_list['iw'],
			self.size_list['ih'],
			self.size_list['z']
		)
		
	#=======================
	#ウィンドウ サイズ変更
	def on_resize(self, event):
		
		if event.widget == self.ImageViewer:
			if (
				int(self.size_list['nw']) == int(event.width) and
				int(self.size_list['nh']) == int(event.height - int(self.size_list['f'] * 2.5))
			):
				return False
			self.size_list['fit_z'] = 0
			self.size_list['nw'] = event.width
			self.size_list['nh'] = event.height - int(self.size_list['f'] * 2.5)
			self.canvas.config(width=self.size_list['nw'], height=self.size_list['nh'])
			
			self.image_center()
			self.image_set()
	
	#=======================
	#画像表示位置を中央へ
	def image_center(self):
		self.size_list['pw'] = int(self.size_list['nw'] / 2)
		self.size_list['ph'] = int(self.size_list['nh'] / 2)
	
	#=======================
	#画像変更
	def image_set(self,mode=0):
		
		cha_img = self.file_list2[self.file_index]
		if os.path.isfile(cha_img) == False:
			return False
		
		sep_dir = cha_img.split(os.sep)
		self.img_name = sep_dir.pop()
		
		try:
			self.def_image = Image.open(cha_img)
			if self.def_image.mode == 'CMYK':
				base_dir = os.path.dirname(sys.executable)
				icc_file = os.path.join(base_dir, 'AdobeRGB1998.icc')
				
				if os.path.isfile(icc_file) == False:
					icc_file = './AdobeRGB1998.icc'
				
				icc = self.def_image.info.get("icc_profile")
				if icc:
					srgb = io.BytesIO(icc)
					self.def_image = ImageCms.profileToProfile(
						self.def_image,
						srgb,
						icc_file,
						renderingIntent=0,
						outputMode="RGB")
				
			change_pos = self.image_size_check(self.def_image)
			
			if self.size_list['fit'] == False:
				zoom_num = self.size_list['z'] / 100
				self.def_image = self.def_image.resize((int(self.size_list['iw'] * zoom_num),int(self.size_list['ih'] * zoom_num)))
				
			else:
				self.size_list['z'] = int(self.size_list['sw'] / self.size_list['iw'] * 100)
				if change_pos['resize'] == True:
					self.def_image = self.def_image.resize((change_pos['sw'],change_pos['sh']))
			
			self.set_image = ImageTk.PhotoImage(self.def_image, master=self.ImageViewer)
			
			if self.canvas_image is None:
				self.canvas_image = self.canvas.create_image(change_pos['mw'], change_pos['mh'], image=self.set_image)
			else:
				self.canvas.itemconfig(self.canvas_image, image=self.set_image)
			
			self.canvas.coords(self.canvas_image, self.size_list['pw'],self.size_list['ph'])
			
			self.info_set()
		
		except:
			self.info_label["text"] ='「{}」この画像は表示できません'.format(self.img_name)
		
	#=======================
	#画像サイズチェック
	def image_size_check(self, ch_image):
		
		change_pos = {'mw':0,'mh':0,'sw':0,'sh':0,'flag':False,'resize':False}
		change_pos['mw'] = int(self.size_list['nw'] / 2)
		change_pos['mh'] = int(self.size_list['nh'] / 2)
		
		self.size_list['iw'] = ch_image.width
		self.size_list['ih'] = ch_image.height
		
		if self.size_list['nw'] < self.size_list['iw'] and self.size_list['nh'] < self.size_list['ih']:
			change_pos['resize'] = True
			if self.size_list['iw'] < self.size_list['ih']:
				change_pos['sh'] = self.size_list['nh']
				change_pos['sw'] = int(self.size_list['iw'] * (change_pos['sh'] / self.size_list['ih']))
			
			else:
				change_pos['sw'] = self.size_list['nw']
				change_pos['sh'] = int(self.size_list['ih'] * (change_pos['sw'] / self.size_list['iw']))
			
		elif self.size_list['nw'] < self.size_list['iw']:
			change_pos['resize'] = True
			change_pos['sw'] = self.size_list['nw']
			change_pos['sh'] = int(self.size_list['ih'] * (change_pos['sw'] / self.size_list['iw']))
			
		elif self.size_list['nh'] < self.size_list['ih']:
			change_pos['resize'] = True
			change_pos['sh'] = self.size_list['nh']
			change_pos['sw'] = int(self.size_list['iw'] * (change_pos['sh'] / self.size_list['ih']))
		
		#========================
		if self.size_list['nh'] < change_pos['sh']:
			change_pos['sh'] = self.size_list['nh']
			change_pos['sw'] = int(self.size_list['iw'] * (change_pos['sh'] / self.size_list['ih']))
		
		elif self.size_list['nw'] < change_pos['sw']:
			change_pos['sw'] = self.size_list['nw']
			change_pos['sh'] = int(self.size_list['ih'] * (change_pos['sw'] / self.size_list['iw']))
		
		if change_pos['sw'] == 0:
			self.size_list['sw'] = ch_image.width
			self.size_list['sh'] = ch_image.height
		else:
			self.size_list['sw'] = change_pos['sw']
			self.size_list['sh'] = change_pos['sh']
		
		if self.size_list['fit_z'] == 0:
			self.size_list['fit_z'] = int(self.size_list['sw'] / self.size_list['iw'] * 100)
		
		return change_pos

#=======================
try:
	main_img = sys.argv[1]

except:
	sys.exit()

pyImg = pyImageViewer()

pythonで画像ビュアーを作る
動作画面

機能の解説

機能解説になりますが、正直なところフォトへのアンチテーゼです。
フォトビューアーにあってフォトでなくなった機能を搭載しています。

まず個人的にフォトで使いにくくなった点として

・画像送りがループしなくなった
・画像送り後にサイズが調整される
・マウスの4,5ボタンに対応していない
・拡大縮小がアニメーションする
・不要なボタンがめちゃくちゃ多い

といった点が挙げられます。
特に困ったのはループとマウスの4,5ボタン。

ループはどうしようもないとして、
マウスだけで画像送りをしたければ一応画面隅に移動ボタンがあります。
が、なぜフォトビューアーでできた機能を削ったのかと。

なおフォトで4,5ボタンを使うと概ね左クリックと同じ挙動をします。

あと画像送り後の調整は画像の位置が変だったり拡大されていたりで非常に見辛い。

機能一覧

対応させた機能は

・画像クリックで原寸拡大
・ドラッグではなくマウスの移動だけでで閲覧位置の調整
・キーボードの左右キーとマウスの4,5ボタンで画像送り
・画像送りはループする
・ホイールで拡大縮小(最小10% ~ 最大100%)

といったところです。
あくまで個人用に作ったのでGUI面は放棄しています。
フォトビューアーでも原寸拡大ボタンぐらいしか使わなかったので。

4,5ボタンはボタン番号でしか検出できなかったので「進む/戻る」に対応しているかは不明です。

exeファイル化

最終的にpyinstallerを使ってexeファイルにしております。

自己署名証明書を作成して署名もしておいたのですが、
その辺りの設定はこちらのページで非常にわかりやすく書かれていたのでリンクを掲載しておきます。

【PowerShell】自作の.exeファイルを自己署名を入れる方法 / しぐにゃもブログ

欠点

・起動が多少遅い(pyinstallerの設定による)
・ウインドウサイズを記録しないので、開く度に調整が必要
・メモリ消費量問題(後述)
・画像サイズ変更が重い
・CMYKの色調が変わる

欠点はこういったものですが、特に問題なのがメモリ消費量。
このビューアーは画像を表示する時にPillowが一度メモリへ展開します。

つまり画像が大きければ大きいほどメモリを消費するということです。

初期ウインドウサイズであればだいたい20~40MBほどですが
6000pxぐらいの画像を原寸表示すると400MBぐらいは消費し始めます。

photoImageのzoomメソッドを使えばどうかと思ったのですが
pillowのImageTkで作ったphotoImageにはzoomメソッドが入ってないらしい。
canvasの見た目だけ拡大できるような機能があればいいのですけどね。

またCMYK画像を表示するにはadobeが公開しているiccを用意する必要があります。
exeと同フォルダに置いておけば読み込まれます。
iccファイルがない状態だと「この画像は表示できません」エラーになります。
そして表示できても多少暗い画像になります。

教訓

GUIアプリは割と簡単に作れる。