【没钱买ps,PyQt自己写】Day 25 - project / 自己做一个影片播放器 DIY video player (结合 PyQt + OpenCV)

看完这篇文章你会得到的成果图

此篇文章的范例程序码 github

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

之前内容的重点复习 (前情提要)

我们接下来的讨论,会基於读者已经先读过我 day5 文章 的架构下去进行程序设计
如果还不清楚我程序设计的逻辑 (UI.py、controller.py、start.py 分别在干麻)
建议先阅读 day5 文章後再来阅读此文。

https://www.wongwonggoods.com/python/pyqt5-5/

设计我们的 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. 我设计的显示框为 800x450,等於 16:9,
    符合目前最常见的影片比例 1920x1080、1280x720

转换 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


★ 本文也同步发於我的个人网站(会有内容目录与显示各个小节,阅读起来更流畅):【PyQt5】Day 25 project / 自己做一个影片播放器 DIY video player (结合 PyQt + OpenCV)


<<:  自我成长书单分享

>>:  [Day25]ISO 27001 附录 A.13 通讯安全

自动化测试,让你上班拥有一杯咖啡的时间 | Day 19 - 如何写入档案和读取档案

此系列文章会同步发文到个人部落格,有兴趣的读者可以前往观看喔。 writeFile() 语法 cy....

【Day 01】- 孤灯蓑冠衣,独究程序码:前言与大纲

Agenda 资安宣言 自我介绍与参赛动机 适合阅读本系列文章的对象 本系列文章大纲 目标与展望 好...

【C language part 4】阵列与字串&函式

阵列 阵列是一群具有相同名称或资料型态的变数集合。 由於整个阵列中的变数均具有相同的名称,因此若要存...

Android Studio初学笔记-Day10-RadioButton

RadioButton(多选一按钮) RadioButton是个选择项目的功能,今天透过一个买车票的...

Day06-CRUD API 实作(六)CRUD 实作(下)

大家好~ 今天要来完成我们留言的读取、更新与删除功能罗。 Controller Read 查询全部留...