➣ Reading Time: 26 minutes

看完這篇文章你會得到的成果圖

此篇文章的範例程式碼 github

https://github.com/howarder3/ironman2021_PyQt5_photoshop/tree/main/day25_video_player_project

之前內容的重點複習 (前情提要)

我們接下來的討論,會基於讀者已經先讀過我 day5 文章 的架構下去進行程式設計
如果還不清楚我程式設計的邏輯 (UI.py、controller.py、start.py 分別在幹麻)
建議先閱讀 day5 文章後再來閱讀此文。

設計我們的 UI

我們在裡面加入了一些我們需要的元素:

  • self.button_stop:停止鍵
  • self.button_play:播放鍵
  • self.button_pause:暫停鍵
  • self.button_openfile:開啟檔案鍵

  • self.label_videoframe:顯示畫面

  • self.label_framecnt:顯示目前 frame 數/ 全部 frame 數
  • self.label_filepath:顯示檔案路徑

一些 UI 設計小細節

  1. 與之前設計圖片不同的是,我們拿掉了可以捲動的滑條,
    我希望能夠強制更改比例以符合視窗 (方便一個視窗就能瀏覽)。

  2. 我設計的顯示框為 800×450,等於 16:9,
    符合目前最常見的影片比例 1920×1080、1280×720

轉換 day25.ui -> UI.py

pyuic5 -x day25.ui -o UI.py

執行看看 UI.py 畫面是否如同我們想像

一樣,這程式只有介面 (視覺上的呈現),沒有任何互動功能

  • 看看我們製作出來的介面
python UI.py

設計我們的 controller

設計使用狀態 (state)

我們要設計一個播放器,我們必須要想好播放器的架構可能會有哪幾種 ”state (狀態)“,
我們可以簡單地想一下:

  • 按下 play 後,進行 play 狀態,影片播放
  • 按下 pause 後,進行 pause 狀態,影片暫停
  • 按下 stop 後,進行 stop 狀態,影片回到第一格

而剛開始載入影片時,我們選擇的狀態是 pause,因為暫停狀態才可以任意變更 frame 值 (後續的應用),
而停止狀態永遠都會回到第一格。

以上大概就是我們設計的 state。

設計 video_controller.py

正如同我們前面的文章,這次我們把 img_controller 修改為 video_controller,
並加入類似的功能。

from PyQt5 import QtCore 
from PyQt5.QtGui import QImage, QPixmap
from PyQt5.QtCore import QTimer 

from opencv_engine import opencv_engine

# videoplayer_state_dict = {
#  "stop":0,   
#  "play":1,
#  "pause":2     
# }

class video_controller(object):
    def __init__(self, video_path, ui):
        self.video_path = video_path
        self.ui = ui
        self.qpixmap_fix_width = 800 # 16x9 = 1920x1080 = 1280x720 = 800x450
        self.qpixmap_fix_height = 450
        self.current_frame_no = 0
        self.videoplayer_state = "stop"
        self.init_video_info()
        self.set_video_player()

    def init_video_info(self):
        videoinfo = opencv_engine.getvideoinfo(self.video_path)
        self.vc = videoinfo["vc"] 
        self.video_fps = videoinfo["fps"] 
        self.video_total_frame_count = videoinfo["frame_count"] 
        self.video_width = videoinfo["width"]
        self.video_height = videoinfo["height"] 

    def set_video_player(self):
        self.timer=QTimer() # init QTimer
        self.timer.timeout.connect(self.timer_timeout_job) # when timeout, do run one
        # self.timer.start(1000//self.video_fps) # start Timer, here we set '1000ms//Nfps' while timeout one time
        self.timer.start(1) # but if CPU can not decode as fast as fps, we set 1 (need decode time)

    def __get_frame_from_frame_no(self, frame_no):
        self.vc.set(1, frame_no)
        ret, frame = self.vc.read()
        self.ui.label_framecnt.setText(f"frame number: {frame_no}/{self.video_total_frame_count}")
        return frame

    def __update_label_frame(self, frame):       
        bytesPerline = 3 * self.video_width
        qimg = QImage(frame, self.video_width, self.video_height, bytesPerline, QImage.Format_RGB888).rgbSwapped()
        self.qpixmap = QPixmap.fromImage(qimg)

        if self.qpixmap.width()/16 >= self.qpixmap.height()/9: # like 1600/16 > 90/9, height is shorter, align width
            self.qpixmap = self.qpixmap.scaledToWidth(self.qpixmap_fix_width)
        else: # like 1600/16 < 9000/9, width is shorter, align height
            self.qpixmap = self.qpixmap.scaledToHeight(self.qpixmap_fix_height)
        self.ui.label_videoframe.setPixmap(self.qpixmap)
        # self.ui.label_videoframe.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop) # up and left
        self.ui.label_videoframe.setAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter) # Center

    def play(self):
        self.videoplayer_state = "play"

    def stop(self):
        self.videoplayer_state = "stop"

    def pause(self):
        self.videoplayer_state = "pause"

    def timer_timeout_job(self):
        frame = self.__get_frame_from_frame_no(self.current_frame_no)
        self.__update_label_frame(frame)

        if (self.videoplayer_state == "play"):
            self.current_frame_no += 1

        if (self.videoplayer_state == "stop"):
            self.current_frame_no = 0

        if (self.videoplayer_state == "pause"):
            self.current_frame_no = self.current_frame_no

我們開始來慢慢解釋這些東西。

播放邏輯

這邊我們使用 Qtimer,原因很簡單,每支影片都有他自己的 fps,
我們透過計算可以得到「我們應該每多少毫秒,就該換下一個 frame 顯示」。

我們用 frame number 來管理現在要顯示哪一個畫面,
而控制 frame number 的就是我們目前 state 的狀態,以每一個 QTimer timeout 的頻率更新。

def set_video_player(self):
    self.timer=QTimer() # init QTimer
    self.timer.timeout.connect(self.timer_timeout_job) # when timeout, do run one
    # self.timer.start(1000//self.video_fps) # start Timer, here we set '1000ms//Nfps' while timeout one time
    self.timer.start(1) # but if CPU can not decode as fast as fps, we set 1 (need decode time)

def timer_timeout_job(self):
    frame = self.__get_frame_from_frame_no(self.current_frame_no)
    self.__update_label_frame(frame)

    if (self.videoplayer_state == "play"):
        self.current_frame_no += 1

    if (self.videoplayer_state == "stop"):
        self.current_frame_no = 0

    if (self.videoplayer_state == "pause"):
        self.current_frame_no = self.current_frame_no

但這邊我們在執行後才發現我們雖然邏輯正確,但想得太美了
OpenCV 在 decode 所需要花的時間大於我們想要控制的顯示時間,
(簡單來說,decode 太久,導致沒辦法在依照我們想要的 fps 播放)
所以我先暫時改成 self.timer.start(1),讓我們只休息 1ms,
但畢竟 QT 是以 multithread 在進行操作,
這段優化的空間可能要改以 multiprocess 進行才能夠讓我們影片順暢的播放 (這個比較不是此系列重點,有空我們再來實作)

播放鍵相關 (play, stop, pause)

def play(self):
    self.videoplayer_state = "play"

def stop(self):
    self.videoplayer_state = "stop"

def pause(self):
    self.videoplayer_state = "pause"

這邊我使用的邏輯,就是讓按鍵會直接更改到 state 的狀態,
而 state 會去控制現在視窗要顯示的 frame

取得 frame 的圖片,並更新至 UI 介面上

從上面應該可以理解一些小細節,我們用 frame number 來管理我們要顯示的 frame,
而我們透過 frame number 取得 frame 影像的機制,我們寫在 __get_frame_from_frame_no() 當中。

而取得介面後,並更新於 UI 介面的機制,我們寫在 __update_label_frame() 當中。

這邊也有個小細節,我們會自動以 16:9 為基準去看讀入影片的比例,

  • 如果很明顯是較寬 例如 160:9,我們就以寬 (160) 為基準去縮放影片大小
  • 如果很明顯是較高 例如 16:90,我們就以高 (90) 為基準去縮放影片大小
def __get_frame_from_frame_no(self, frame_no):
    self.vc.set(1, frame_no)
    ret, frame = self.vc.read()
    self.ui.label_framecnt.setText(f"frame number: {frame_no}/{self.video_total_frame_count}")
    return frame

def __update_label_frame(self, frame):       
    bytesPerline = 3 * self.video_width
    qimg = QImage(frame, self.video_width, self.video_height, bytesPerline, QImage.Format_RGB888).rgbSwapped()
    self.qpixmap = QPixmap.fromImage(qimg)

    if self.qpixmap.width()/16 >= self.qpixmap.height()/9: # like 1600/16 > 90/9, height is shorter, align width
        self.qpixmap = self.qpixmap.scaledToWidth(self.qpixmap_fix_width)
    else: # like 1600/16 < 9000/9, width is shorter, align height
        self.qpixmap = self.qpixmap.scaledToHeight(self.qpixmap_fix_height)
    self.ui.label_videoframe.setPixmap(self.qpixmap)
    # self.ui.label_videoframe.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop) # up and left
    self.ui.label_videoframe.setAlignment(QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter) # Center

設計 opencv_engine.py

讀取影片的資訊

相信有閱讀之前文章的讀者應該都不陌生,
而這邊我們要透過 opencv 協助我們完成影片的讀取,並分析一些資訊。

程式被呼叫的地方在 video_controller 的 init_video_info,
我們把所有必要的影片資訊封裝成一個 dict 回傳。

def init_video_info(self):
    videoinfo = opencv_engine.getvideoinfo(self.video_path)
    self.vc = videoinfo["vc"] 
    self.video_fps = videoinfo["fps"] 
    self.video_total_frame_count = videoinfo["frame_count"] 
    self.video_width = videoinfo["width"]
    self.video_height = videoinfo["height"] 

所以我們在 opencv_engine.py 實作一個新的方法。

@staticmethod
def getvideoinfo(video_path): 
    # https://docs.opencv.org/4.5.3/dc/d3d/videoio_8hpp.html
    videoinfo = {}
    vc = cv2.VideoCapture(video_path)
    videoinfo["vc"] = vc
    videoinfo["fps"] = vc.get(cv2.CAP_PROP_FPS)
    videoinfo["frame_count"] = int(vc.get(cv2.CAP_PROP_FRAME_COUNT))
    videoinfo["width"] = int(vc.get(cv2.CAP_PROP_FRAME_WIDTH))
    videoinfo["height"] = int(vc.get(cv2.CAP_PROP_FRAME_HEIGHT))
    return videoinfo

把一些我們感興趣的資訊都存進 videoinfo 裡面,並回傳。

設計 controller.py

設計按鍵與功能的連結、開啟檔案

def setup_control(self):
    self.ui.button_openfile.clicked.connect(self.open_file)

def open_file(self):
    filename, filetype = QFileDialog.getOpenFileName(self, "Open file Window", "./", "Video Files(*.mp4 *.avi)") # start path        
    self.video_path = filename
    self.video_controller = video_controller(video_path=self.video_path,
                                             ui=self.ui)
    self.ui.label_filepath.setText(f"video path: {self.video_path}")
    self.ui.button_play.clicked.connect(self.video_controller.play) # connect to function()
    self.ui.button_stop.clicked.connect(self.video_controller.stop)
    self.ui.button_pause.clicked.connect(self.video_controller.pause)

我們先讓開啟檔案按鍵的功能連結起來,
開檔成功之後,才綁定按鍵的功能,這些功能定義在 video_controller 中。
我們預期所有的按鍵行為應該是在「開檔後」才會執行 (例如:沒讀取影片,沒必要讓「播放」有功能。)

測試結果

我們目前有一個很 lag 的 video player,
原因可能是因為 decode 速度不夠快,可能可以透過 multiprocess 優化。

後續:後來有找到原因,為 vc.set 反覆執行會吃掉大量程式效率,之後文章會再分享該如何修正

Reference

⭐Python PyQt5 相關文章整理⭐:
⭐基礎知識與架構篇⭐:
1.【PyQt5】Day 1 – 安裝 PyQt,建立自己的第一支 PyQt5 程式
2.【PyQt5】Day 2 – 利用 Qt designer 建立第一支有自己介面的 PyQt5 程式
3.【PyQt5】Day 4 – 重要的 Qt 程式邏輯觀念,務必先有此觀念後面才會懂自己在幹嘛
4.【PyQt5】Day 5 – 開始來設計我們的 controller.py,改以「程式角度」來說明如何建立 PyQt 的系統
⭐基本元件應用篇⭐:
1.【PyQt5】Day 6 – 我們的第一個 output 手段 – Qlabel
2.【PyQt5】Day 7 – 我們的第一個 input 手段 – QPushButton
3.【PyQt5】Day 8 – 我們的第二個 input 手段 – QLineEdit
4.【PyQt5】Day 9 – 以 QLineEdit, QTextEdit, QPlainTextEdit 作為文字的輸入
6.【PyQt5】Day 14 - 使用 QSlider 製作可拖曳的滑條
8.【PyQt5】Day 19 - 使用 QProgressBar,製作進度條的功能
⭐介面系統控制篇⭐:
5.【PyQt5】Day 10 – 以 QFileDialog 讀取系統的檔案、資料夾
7.【PyQt5】Day 18 / Project 使用 QTimer,自製碼表(計時器) PyQt5 stopwatch DIY
1.【PyQt5】Day 20 - PyQt 最重要的 QThread 概念 / 為什麼 windows, mac, ubuntu (linux) 程式會「沒有回應」?
2.【PyQt5】Day 21 – 透過 PyQt 實現滑鼠監聽總整理,完全掌握滑鼠控制 (listen mouse)
3.【PyQt5】Day 22 – PyQt 視窗的個性化/屬性控制 setWindowFlags,禁止放大縮小、永遠顯示於最上層/最下層
4.【PyQt5】Day 23 – 使用系統內建的調色盤 QColorDialog,來替我們選擇顏色 QColor (Color Picker)
5.【PyQt5】Day 24 / Project 偵測滑鼠目前指示顏色的小工具 (滴管工具), 利用 QCursor 偵測滑鼠, QApplication 取得截圖
⭐影像處理篇⭐:
1.【PyQt5】Day 11 – 以 Qlabel 在 PyQt 中顯示圖片 (基於 QImage 使用 OpenCV)
2.【PyQt5】Day 12 – 建立一個可以縮放圖片大小的顯示器 (基於 QImage 使用 OpenCV)
3.【PyQt5】Day 13 – 使用 QVBoxLayout, QscrollArea 製作出捲軸,以高解析度檢視圖片 (基於 QImage 使用 OpenCV)
4.【PyQt5】Day 15 / Project 與檔案功能整合,製作出可讀取圖片並可縮放的 UI 介面 (使用 PyQt + OpenCV)
5.【PyQt5】Day 16 - 在 PyQt5 中取得圖片座標 (滑鼠位置) mousePressEvent,觀察圖片在 Qt 中產生的方式,對原圖進行座標換算處理
6.【PyQt5】Day 17 / Project 製作標註 roi 工具, 開始導入 OpenCV 作為繪圖引擎, 在圖上畫點並顯示座標
⭐project 篇⭐:
1.【PyQt5】Day 25 / Project 自己做一個影片播放器 DIY video player (結合 PyQt + OpenCV)
2.【PyQt5】Day 26 / Project 替我們影片播放器增加一個顯示進度的滑條 video player add slider (與昨日 bottleneck 處理細節)
3.【PyQt5】Day 27 / Project 製作影片 ROI 標註工具 (PyQt 結合 OpenCV 在圖上畫點畫線)
4.【PyQt5】Day 28 / final project – 1 來搞一個自己的 photoshop 吧!UI 篇 + 純程式架構篇 (結合 PyQt + OpenCV)
5.【PyQt5】Day 29 / final project – 2 來搞一個自己的 photoshop 吧!後段程式細節篇 (結合 PyQt + OpenCV)
6.【PyQt5】Day 30 / final project – 3 來搞一個自己的 photoshop 吧!把每個方法封裝起來製作出還原功能吧! (結合 PyQt + OpenCV)
⭐打包程式篇⭐:
1.【PyQt5】Day 3 – 用 pyinstaller 將 python 程式打包,把每天的成果分享給你的親朋好友
⭐【喜歡我的文章嗎? 歡迎幫我按讚~ 讓基金會請創作者喝一杯咖啡!
如果喜歡我的文章,請幫我在下方【按五下Like】 (Google, Facebook 免註冊),會由 「LikeCoin」 贊助作者鼓勵繼續創作,讀者們「只需幫忙按讚,完全不用出錢」哦!

likecoin-steps