【Python】Tkinterで簡易的な動画プレイヤーを作る

# はじめに

Pythonの標準ライブラリであるtkinterで動画を再生する方法とそれを使ったアプリの作り方を紹介します。

## この記事の対象者

- tkinterについての基本的な知識があり、ウィンドウで動画を再生したい。

## 前提知識

- 基本的なPythonの記法と仕様を理解している
- tkinter, opencv-python, pillowの基本的な使い方を知っている

# 実装

## 必要なライブラリのインストール

この記事ではOpenCV(opencv-python)とPillowを使った方法を紹介するので、それをインストールします。
```bash
pip install -U opencv-python pillow
```

## 実際のコード

先にコードの全体を紹介します。
```python
import tkinter as tk
from tkinter import ttk

import cv2
from PIL import Image, ImageTk


class VideoPlayer:

    def __init__(self, window, video_source=0):
        self.window = window
        self.window.title("Video Player")
        self.video_source = video_source
        self.vid = cv2.VideoCapture(self.video_source)

        self.canvas = tk.Canvas(window)
        self.canvas.grid(row=0, column=0, columnspan=5)

        self.btn_rewind = tk.Button(window, text="<< 5s", width=10, command=self.rewind)
        self.btn_rewind.grid(row=1, column=0, sticky="ew")

        self.btn_play_pause = tk.Button(window, text="Play", width=10, command=self.toggle_play)
        self.btn_play_pause.grid(row=1, column=1, sticky="ew")

        self.btn_skip = tk.Button(window, text="5s >>", width=10, command=self.skip)
        self.btn_skip.grid(row=1, column=2, sticky="ew")

        self.progress_bar = ttk.Progressbar(window, orient="horizontal", length=200, mode="determinate")
        self.progress_bar.grid(row=1, column=3, sticky="ew")

        self.lbl_timestamp = tk.Label(window, text="00:00/00:00")
        self.lbl_timestamp.grid(row=1, column=4, sticky="ew")

        self.paused = True
        self.update()

    def toggle_play(self):
        self.paused = not self.paused
        if self.paused:
            self.btn_play_pause.config(text="Play")
        else:
            self.btn_play_pause.config(text="Pause")
            self.update()

    def rewind(self):
        current_frame = int(self.vid.get(cv2.CAP_PROP_POS_FRAMES))
        fps = self.vid.get(cv2.CAP_PROP_FPS)
        target_frame = max(current_frame - 5 * fps, 0)
        self.vid.set(cv2.CAP_PROP_POS_FRAMES, target_frame)

    def skip(self):
        current_frame = int(self.vid.get(cv2.CAP_PROP_POS_FRAMES))
        total_frames = int(self.vid.get(cv2.CAP_PROP_FRAME_COUNT))
        fps = self.vid.get(cv2.CAP_PROP_FPS)
        target_frame = min(current_frame + 5 * fps, total_frames)
        self.vid.set(cv2.CAP_PROP_POS_FRAMES, target_frame)

    def update(self):
        if not self.paused:
            ret, frame = self.vid.read()
            if ret:
                self.photo = ImageTk.PhotoImage(image=Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)))
                self.canvas.config(width=self.vid.get(cv2.CAP_PROP_FRAME_WIDTH), height=self.vid.get(cv2.CAP_PROP_FRAME_HEIGHT))
                self.canvas.create_image(0, 0, image=self.photo, anchor=tk.NW)
                current_frame = int(self.vid.get(cv2.CAP_PROP_POS_FRAMES))
                total_frames = int(self.vid.get(cv2.CAP_PROP_FRAME_COUNT))
                self.progress_bar["value"] = (current_frame / total_frames) * 100
                current_time = int(self.vid.get(cv2.CAP_PROP_POS_MSEC) / 1000)
                total_time = int(total_frames / self.vid.get(cv2.CAP_PROP_FPS))
                current_time_str = self.format_time(current_time)
                total_time_str = self.format_time(total_time)
                self.lbl_timestamp.config(text=f"{current_time_str}/{total_time_str}")
                fps = self.vid.get(cv2.CAP_PROP_FPS)
                delay = int(1000 / fps)
                self.window.after(delay, self.update)
            else:
                self.toggle_play()
        else:
            self.btn_play_pause.config(text="Play")

    def format_time(self, seconds):
        hours = seconds // 3600
        minutes = (seconds % 3600) // 60
        seconds = seconds % 60
        if hours > 0:
            return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
        else:
            return f"{minutes:02d}:{seconds:02d}"


root = tk.Tk()
player = VideoPlayer(root, "your_video_file.mp4")
root.mainloop()
```

## 解説

### `VideoPlayer`

今回作成するアプリケーションのクラスです。

#### `__init__(self, window, video_source=0)`

このクラスのコンストラクタ(初期化メソッド)です。`window`はTkinterの親ウィンドウのインスタンスを受け取り、`video_source`はビデオソースのパスまたはデバイス番号 (デフォルトは`0`=デフォルトカメラ)を受け取ります。
```python
self.window = window
self.window.title("Video Player")
```
受け取った`window`を`self.window`に割り当て、ウィンドウのタイトルを`"Video Player"`に設定します。

```python
self.video_source = video_source
self.vid = cv2.VideoCapture(self.video_source)
```
`video_source`を`self.video_source`に割り当て、`cv2.VideoCapture`で`self.vid`を作成します。これによってビデオキャプチャオブジェクトを初期化します。

```python
self.canvas = tk.Canvas(window)
self.canvas.grid(row=0, column=0, columnspan=5)
```
`self.canvas`はTkinterの`Canvas`ウィジェットを作成し、それをウィンドウのグリッドレイアウトに配置します。`columnspan=5`は、この`Canvas`が`5`つのカラムにまたがることを意味します。

```python
self.btn_rewind = tk.Button(window, text="<< 5s", width=10, command=self.rewind)
self.btn_rewind.grid(row=1, column=0, sticky="ew")
```
`self.btn_rewind`は、`"<< 5s"`というテキストを持つ幅`10`の`Button`ウィジェットを作成します。`command=self.rewind`は、このボタンがクリックされたときに`self.rewind()`メソッドが呼び出されることを意味します。最後に、このボタンをグリッドレイアウトに配置しています。

```python
self.btn_play_pause = tk.Button(window, text="Play", width=10, command=self.toggle_play)
self.btn_play_pause.grid(row=1, column=1, sticky="ew")
```
`self.btn_play_pause`は、`"Play"`というテキストを持つ幅`10`の`Button`ウィジェットを作成します。`command=self.toggle_play`は、このボタンがクリックされたときに`self.toggle_play()`メソッドが呼び出されることを意味します。最後に、このボタンをグリッドレイアウトに配置します。

```python
self.btn_skip = tk.Button(window, text="5s >>", width=10, command=self.skip)
self.btn_skip.grid(row=1, column=2, sticky="ew")
```
`self.btn_skip`は、`"5s >>"`というテキストを持つ幅`10`の`Button`ウィジェットを作成します。`command=self.skip`は、このボタンがクリックされたときに`self.skip()`メソッドが呼び出されることを意味します。最後に、このボタンをグリッドレイアウトに配置します。

```python
self.progress_bar = ttk.Progressbar(window, orient="horizontal", length=200, mode="determinate")
self.progress_bar.grid(row=1, column=3, sticky="ew")
```
`self.progress_bar`は、水平方向の長さ`200`の`ttk.Progressbar`ウィジェットを作成します。`mode="determinate"`は、プログレスバーが確定的な値を表示することを意味します。最後に、このウィジェットをグリッドレイアウトに配置します。

```python
self.lbl_timestamp = tk.Label(window, text="00:00/00:00")
self.lbl_timestamp.grid(row=1, column=4, sticky="ew")
```
`self.lbl_timestamp`は、初期テキスト`"00:00/00:00"`を持つ`tk.Label`ウィジェットを作成します。これは、現在の時間と総時間を表示するために使用されます。最後に、このウィジェットをグリッドレイアウトに配置します。

```python
self.paused = True
self.update()
```
1. `self.paused`は、初期状態で`True`に設定されます。これは、プレイヤーが最初は一時停止状態であることを意味します。
2. `self.update()`メソッドが呼び出されます。このメソッドは後で説明します。

#### `toggle_play(self)`

```python
def toggle_play(self):
    self.paused = not self.paused
    
    if self.paused:
        self.btn_play_pause.config(text="Play")
    else:
        self.btn_play_pause.config(text="Pause")
        
    self.update()
```
このメソッドは`self.paused`の値を反転させます(`True`を`False`に、`False`を`True`に)。
`self.paused`が`True`の場合、`self.btn_play_pause`のテキストを`"Play"`に設定します。`False`の場合は`"Pause"`に設定します。
最後に、`self.update()`メソッドを呼び出します。

#### `rewind(self)`

```python
def rewind(self):
    current_frame = int(self.vid.get(cv2.CAP_PROP_POS_FRAMES))
    fps = self.vid.get(cv2.CAP_PROP_FPS)
    target_frame = max(current_frame - 5 * fps, 0)
    self.vid.set(cv2.CAP_PROP_POS_FRAMES, target_frame)
```
このメソッドは、動画を`5`秒巻き戻します。
`current_frame`は現在のフレーム番号を取得します。
`fps`は動画のフレームレートを取得します。
`target_frame`は目標のフレーム番号を計算します。`current_frame - 5 * fps`で5秒前のフレームを計算し、`max(_, 0)`で0未満にならないようにしています。
`self.vid.set(cv2.CAP_PROP_POS_FRAMES, target_frame)`でビデオキャプチャの現在のフレーム位置を`target_frame`に設定します。

#### `skip(self)`

```python
def skip(self):
    current_frame = int(self.vid.get(cv2.CAP_PROP_POS_FRAMES))
    total_frames = int(self.vid.get(cv2.CAP_PROP_FRAME_COUNT))
    fps = self.vid.get(cv2.CAP_PROP_FPS)
    target_frame = min(current_frame + 5 * fps, total_frames)
    self.vid.set(cv2.CAP_PROP_POS_FRAMES, target_frame)
```
このメソッドは動画を`5`秒早送りします。
`current_frame`と`total_frames`は現在のフレーム番号と総フレーム数を取得します。
`fps`は、動画のフレームレートを取得します。
`target_frame`は目標のフレーム番号を計算します。`current_frame + 5 * fps`で5秒後のフレームを計算し、`min(_, total_frames)`で総フレーム数を超えないようにしています。
`self.vid.set(cv2.CAP_PROP_POS_FRAMES, target_frame)`でビデオキャプチャの現在のフレーム位置を`target_frame`に設定します。

#### `update(self)`

```python
def update(self):
    if not self.paused:
        ret, frame = self.vid.read()
        if ret:
            self.photo = ImageTk.PhotoImage(image=Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)))
            self.canvas.config(width=self.vid.get(cv2.CAP_PROP_FRAME_WIDTH), height=self.vid.get(cv2.CAP_PROP_FRAME_HEIGHT))
            self.canvas.create_image(0, 0, image=self.photo, anchor=tk.NW)

            current_frame = int(self.vid.get(cv2.CAP_PROP_POS_FRAMES))
            total_frames = int(self.vid.get(cv2.CAP_PROP_FRAME_COUNT))
            self.progress_bar["value"] = (current_frame / total_frames) * 100

            current_time = int(self.vid.get(cv2.CAP_PROP_POS_MSEC) / 1000)
            total_time = int(total_frames / self.vid.get(cv2.CAP_PROP_FPS))
            current_time_str = self.format_time(current_time)
            total_time_str = self.format_time(total_time)
            self.lbl_timestamp.config(text=f"{current_time_str}/{total_time_str}")

            fps = self.vid.get(cv2.CAP_PROP_FPS)
            delay = int(1000 / fps)
            self.window.after(delay, self.update)
        else:
            self.toggle_play()
    else:
        self.btn_play_pause.config(text="Play")
```

このメソッドは、動画の再生とGUIの更新を行います。

`if not self.paused:`で一時停止状態でない場合に以下の処理を行います。

`ret, frame = self.vid.read()`で次のフレームを読み込みます。`ret`は読み込みの成功/失敗を示すブール値です。

`ret`が`True`の場合:
`self.photo`に、フレームをTkinter用の画像オブジェクトに変換したものを設定します。
`self.canvas`の幅と高さをフレームのサイズに合わせて設定します。
`self.canvas`に`self.photo`画像を表示します。
`current_frame`と`total_frames`を取得し、`self.progress_bar["value"]`にその割合を設定します。
`current_time`と`total_time`(秒数)を計算し、`self.format_time`関数を使って文字列をフォーマットします。
`self.lbl_timestamp`にその時間文字列を設定します。
`fps`を取得し、次のフレームの遅延時間`delay`を計算します。
`self.window.after(delay, self.update)`で`delay`ミリ秒後に`self.update`を再び呼び出すことで、動画が正常な速度で再生されます。

`ret`が`False`の場合:
`self.toggle_play()`を呼び出して再生を停止します。

`if not self.paused:`が`else`節を実行した場合、つまり一時停止状態の場合は`self.btn_play_pause`のテキストを`"Play"`に設定します。

#### `format_time(self)`
```python
def format_time(self, seconds):
    hours = seconds // 3600
    minutes = (seconds % 3600) // 60
    seconds = seconds % 60
    
    if hours > 0:
        return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
    else:
        return f"{minutes:02d}:{seconds:02d}"
```
このメソッドは秒数を`"HH:MM:SS"`形式の文字列に変換します。
`hours`、`minutes`、`seconds`を計算します。
`hours`が`0`より大きい場合は`"HH:MM:SS"`形式で返します。そうでない場合は`"MM:SS"`形式で返します。

### 実行部分

```python
root = tk.Tk()
player = VideoPlayer(root, "your_video_file.mp4")
root.mainloop()
```
`root = tk.Tk()`で、Tkinterの親ウィンドウを作成します。
`player = VideoPlayer(root, "your_video_file.mp4")`で`VideoPlayer`クラスのインスタンスを作成します。`root`をウィンドウとして渡し、`"your_video_file.mp4"`を動画ソースとして渡します。
`root.mainloop()`でTkinterのメインループを開始し、ウィンドウが閉じられるまでプログラムが実行され続けます。

# 終わりに

最後まで読んで頂きありがとうございます。不備があれば是非コメントで指摘して下さい。