LoginSignup
3
2

概要

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と謳っているように、実際にこのような計算機を簡単に作ることができます。(もちろんボタンもマウスで押せます)
image.png

こちらのページではTextualを使用して開発されたアプリも紹介されています。

導入

pipでインストールするだけです。

$ pip install textual textual-dev

以下で簡単なデモが起動します。

$ python -m textual

基本的にはウィジェットと呼ばれる部品を配置して、cssでレイアウトを調整する仕組みです。
ストップウォッチを作成するチュートリアルがあるので、それをやってみると一通り理解できます。

画像ビューワーを作ってみる

画像ビューワーアプリを作ってみました。ターミナル上で画像を見たいときはimg2sixelコマンドを使って確認していましたが、ざっと画像一覧を確認したいときが面倒だったので、ファイル一覧から選択して表示できるようなものを作りました。
画質は落ちてしまいますが、どういう画像か確認するくらいなら問題ないレベルです。

imagebrowser.gif

コードは以下になります。

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側で色を調節すれば親和性を高めることもできそうです。

image.png

まとめ

Textualで簡単にTUIアプリを作成することができました。ウィジェットが多数用意されているので、パーツの配置が本当に楽でした。
ロードマップも公開されていて、今後機能も増えていきそうです。
オレオレツールを作るのに役立てるのではないかと思います。

3
2
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
3
2