概要
PythonのTUIフレームワークTextualを使って、ターミナル上で画像を表示するTUIアプリを作ってみましたので紹介します。
Textualとは?
Textualとは公式では以下のように紹介されています。
Textual is a Rapid Application Development framework for Python, built by Textualize.io.
Build sophisticated user interfaces with a simple Python API. Run your apps in the terminal or a web browser!
Rapidと謳っているように、実際にこのような計算機を簡単に作ることができます。(もちろんボタンもマウスで押せます)
こちらのページではTextualを使用して開発されたアプリも紹介されています。
導入
pipでインストールするだけです。
$ pip install textual textual-dev
以下で簡単なデモが起動します。
$ python -m textual
基本的にはウィジェットと呼ばれる部品を配置して、cssでレイアウトを調整する仕組みです。
ストップウォッチを作成するチュートリアルがあるので、それをやってみると一通り理解できます。
画像ビューワーを作ってみる
画像ビューワーアプリを作ってみました。ターミナル上で画像を見たいときはimg2sixelコマンドを使って確認していましたが、ざっと画像一覧を確認したいときが面倒だったので、ファイル一覧から選択して表示できるようなものを作りました。
画質は落ちてしまいますが、どういう画像か確認するくらいなら問題ないレベルです。
コードは以下になります。
image_browser.py
from datetime import datetime
import sys
import pathlib
from PIL import Image
import numpy as np
from rich.traceback import Traceback
from rich.text import Text
from rich_pixels import Pixels
from textual import log
from textual.app import App, ComposeResult
from textual.containers import Container, VerticalScroll, Vertical
from textual.reactive import var
from textual.widgets import DirectoryTree, Footer, Header, Static, Label
import plotext as plt
class ImageView(Static):
path: pathlib.Path = None
hists = None
last_updated = None
file_size = None
def set_path(self, path: pathlib.Path):
self.path = path
self._show_image(self.path)
def on_resize(self):
if self.path:
self._show_image(self.path)
def _show_image(self, path: pathlib.Path):
try:
with Image.open(path) as image:
stat = path.stat()
self.last_updated = datetime.fromtimestamp(stat.st_mtime)
self.file_size = self._sizeof_fmt(stat.st_size)
self.hists = np.split(np.array(image.histogram()), len(image.getbands()))
scale = self.size.width / image.width
image = image.resize((round(image.width * scale), round(image.height * scale)))
image = Pixels.from_image(image)
except Exception as e:
log.error(e)
self.update(Traceback(theme="github-dark", width=None))
else:
self.update(image)
self.scroll_home(animate=False)
def _sizeof_fmt(self, num, suffix="B"):
for unit in ("", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"):
if abs(num) < 1024.0:
return f"{num:3.1f}{unit}{suffix}"
num /= 1024.0
return f"{num:.1f}Yi{suffix}"
class HistgramView(Static):
def render_graph(self, hists):
plt.clf()
plt.title("Histogram Plot")
plt.plotsize(self.size.width, self.size.height)
for hist, ch in zip(hists, ['R', 'G', 'B']):
plt.plot(hist, label=ch, color=[(255, 0, 0), (0, 255, 0), (0, 0, 255)])
plt.canvas_color('black')
plt.axes_color([(255, 0, 0), (0, 255, 0), (0, 0, 255)])
try:
self.update(Text.from_ansi(plt.build()))
except OSError as e:
log.error(e)
class ImageBrowser(App):
CSS_PATH = "image_browser.tcss"
BINDINGS = [
("f", "toggle_files", "Toggle Files"),
("h", "toggle_hist", "Toggle Histgram"),
("q", "quit", "Quit"),
]
show_tree = var(True)
show_hist = var(True)
def watch_show_tree(self, show_tree: bool) -> None:
self.set_class(show_tree, "-show-tree")
def watch_show_hist(self, show_hist: bool) -> None:
self.set_class(show_hist, "-show-hist")
def compose(self) -> ComposeResult:
path = "./" if len(sys.argv) < 2 else sys.argv[1]
yield Header()
with Container(id='app-grid'):
yield DirectoryTree(path, id="tree-view")
with VerticalScroll(id="image-view"):
yield Label(id="meta-view")
yield ImageView(id="image", expand=True)
yield HistgramView(id="hist-view")
yield Footer()
def on_mount(self) -> None:
self.query_one(DirectoryTree).focus()
def on_directory_tree_file_selected(
self, event: DirectoryTree.FileSelected
) -> None:
event.stop()
image_view = self.query_one("#image", ImageView)
image_view.set_path(event.path)
self.sub_title = event.path
self.show_hist = False
hist_view = self.query_one("#hist-view", HistgramView)
hist_view.render_graph(image_view.hists)
meta_view = self.query_one("#meta-view", Label)
meta_view.update(f'last updated: {image_view.last_updated}\nsize: {image_view.file_size}')
def action_toggle_files(self) -> None:
self.show_tree = not self.show_tree
def action_toggle_hist(self) -> None:
self.show_hist = not self.show_hist
if __name__ == "__main__":
ImageBrowser().run()
image_browser.tcss
Screen {
background: $surface-darken-1;
&:inline {
height: 50vh;
}
}
#app-grid {
layout: grid;
grid-size: 1;
grid-columns: 1fr;
grid-rows: 7fr 100fr;
}
#tree-view {
display: none;
scrollbar-gutter: stable;
overflow: auto;
width: auto;
height: 100%;
dock: left;
}
ImageBrowser.-show-tree #tree-view {
display: block;
max-width: 50%;
}
#image-view {
overflow: auto scroll;
min-width: 100%;
height: 100%;
}
#image {
width: auto;
}
#hist-view {
display: none;
width: 100%;
height: 10000;
}
ImageBrowser.-show-hist #hist-view {
display: block;
width: 100%;
height: 10000;
}
依存ライブラリは以下です。
$ pip install textual textual-dev rich-pixels pillow plotext numpy
レイアウト部分
以下のImageBrowserのcomposeメソッドでレイアウトを定義しています。
Containerの中で左側をファイル選択View(DirectoryTree)、右側を画像View(ImageView)としています。
class ImageBrowser(App):
...
def compose(self) -> ComposeResult:
path = "./" if len(sys.argv) < 2 else sys.argv[1]
yield Header()
with Container(id='app-grid'):
yield DirectoryTree(path, id="tree-view")
with VerticalScroll(id="image-view"):
yield Label(id="meta-view")
yield ImageView(id="image", expand=True)
yield HistgramView(id="hist-view")
yield Footer()
ファイル選択
ファイル一覧で画像ファイルを選択するとDirectoryTreeのコールバックが実行されます。ここで各Viewに情報を渡し更新をかけます。
def on_directory_tree_file_selected(
self, event: DirectoryTree.FileSelected
) -> None:
event.stop()
image_view = self.query_one("#image", ImageView)
image_view.set_path(event.path)
self.sub_title = event.path
self.show_hist = False
hist_view = self.query_one("#hist-view", HistgramView)
hist_view.render_graph(image_view.hists)
meta_view = self.query_one("#meta-view", Label)
meta_view.update(f'last updated: {image_view.last_updated}\nsize: {image_view.file_size}')
画像表示
画像表示はStaticというウィジェットを継承したImageViewを定義しました。
画像を表示するために、Rich Pixelsを使用しています。TextualizeはRichというターミナル出力を美しくするライブラリも公開しており、Rich PixelsもRichを元に作られています。
参考:https://qiita.com/cvusk/items/0c9fdf1fd12097f4a1b4
やっていることは単純で、Pillowで画像を読み込み、Pixels.from_image
を呼び出しているだけです。この結果をImageView自身のupdate
で渡せば画像が表示されます。
また、ImageViewの横幅に合わせて画像をリサイズしています。レスポンシブデザインっぽく、画面幅を変えると追従してサイズが変わるようにしました。
class ImageView(Static):
...
def _show_image(self, path: pathlib.Path):
try:
with Image.open(path) as image:
stat = path.stat()
self.last_updated = datetime.fromtimestamp(stat.st_mtime)
self.file_size = self._sizeof_fmt(stat.st_size)
self.hists = np.split(np.array(image.histogram()), len(image.getbands()))
scale = self.size.width / image.width
image = image.resize((round(image.width * scale), round(image.height * scale)))
image = Pixels.from_image(image)
except Exception as e:
log.error(e)
self.update(Traceback(theme="github-dark", width=None))
else:
self.update(image)
self.scroll_home(animate=False)
おまけ:ヒストグラムを表示する
これのようなグラフを作ってみたかったので、画像のヒストグラムをグラフ化してみました。
plotextを使用しています。
これも単純で、画像読み込み時に計算したヒストグラムをplotextに与えて描画しているだけになります。
class HistgramView(Static):
def render_graph(self, hists):
plt.clf()
plt.title("Histogram Plot")
plt.plotsize(self.size.width, self.size.height)
for hist, ch in zip(hists, ['R', 'G', 'B']):
plt.plot(hist, label=ch, color=[(255, 0, 0), (0, 255, 0), (0, 0, 255)])
plt.canvas_color('black')
plt.axes_color([(255, 0, 0), (0, 255, 0), (0, 0, 255)])
try:
self.update(Text.from_ansi(plt.build()))
except OSError as e:
log.error(e)
このように表示できます。(グラフのデータとしてはイマイチですね。。。)
plotext側で色を調節すれば親和性を高めることもできそうです。
まとめ
Textualで簡単にTUIアプリを作成することができました。ウィジェットが多数用意されているので、パーツの配置が本当に楽でした。
ロードマップも公開されていて、今後機能も増えていきそうです。
オレオレツールを作るのに役立てるのではないかと思います。