본문 바로가기
PyQt5

PyQt5를 이용하여 동영상 플레이어를 만들어보자(3)

by Tripleler 2022. 1. 8.

이전포스팅

https://tripleler.tistory.com/5?category=1015667

 

PyQt5를 이용하여 동영상 플레이어를 만들어보자(2)

프로그래밍에서 중요한 것은 쓰레딩이다. 쓰레딩을 간단히 설명하자면 일반적으로 파이썬 코드는 코드의 짜임 순서대로 처리한다. 따라서 동영상 플레이어같은 프로그램을 만들 때 쓰레드를

tripleler.tistory.com

무릇 동영상 플레이어라면 재생/정지가 가능하고

속도를 스스로 조절할 수 있어야 한다.

 

이번 포스팅에서는 이 두가지 핵심기능을 구현해 보고자 한다,

앞의 두 포스팅은 코드 복사를 허용해놨지만

이번 포스팅의 경우는 공부를 위해서라도 복사를 금지해뒀음을 참고할 것!

 

우선 코드부터 보자!

import sys
from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
from PyQt5.QtCore import *
from PyQt5.QtTest import QTest
import cv2
import numpy as np

app = QApplication(sys.argv)
screen = app.primaryScreen()
size = screen.size()
width = size.width()
height = size.height()


class MyApp(QMainWindow):

    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.cent_widget = CentWidget()
        self.setCentralWidget(self.cent_widget)
        self.setWindowTitle('Video Player')
        self.setGeometry(width // 10, height // 10, width // 5 * 4, height // 5 * 4)
        menubar = self.menuBar()
        file = QMenu('파일', self)
        menubar.addMenu(file)
        load = QAction('불러오기', self)
        file.addAction(load)
        load.triggered.connect(self.cent_widget.load)
        self.show()


class CentWidget(QWidget):
    def __init__(self):
        super().__init__()
        self.videothread = VideoThread()
        base = QPixmap('base.JPG')
        self.lbl_img = QLabel()
        self.lbl_img.setScaledContents(True)
        self.lbl_img.setPixmap(base)
        icon_pp = QIcon()
        icon_pp.addPixmap(QPixmap("./icon/pause.png"), QIcon.Normal, QIcon.On)
        icon_pp.addPixmap(QPixmap("./icon/play.png"), QIcon.Active, QIcon.Off)
        icon_pp.addPixmap(QPixmap("./icon/pause.png"), QIcon.Active, QIcon.On)
        self.btn_pp = QPushButton()
        self.btn_pp.setCheckable(True)
        self.btn_pp.setIcon(icon_pp)
        self.btn_pp.clicked.connect(self.pp)
        grid = QGridLayout()
        grid.addWidget(self.lbl_img, 0, 0, 19, 20)
        grid.addWidget(self.btn_pp, 19, 0, 1, 5)
        self.setLayout(grid)

        self.videothread.send_img.connect(self.show)

    def show(self, frame):
        frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
        img = QImage(frame.data, frame.shape[1], frame.shape[0], frame.shape[2] * frame.shape[1],
                     QImage.Format_RGB888)
        self.lbl_img.setPixmap(QPixmap.fromImage(img))

    def load(self):
        fname = QFileDialog.getOpenFileName(self, '동영상 선택하기', filter='*.mp4')
        if fname[0]:
            if self.videothread.isRunning():
                self.videothread.status = False
                QTest.qWait(1000)
                self.videothread.terminate()
                self.videothread.status = True
            self.videothread.source = fname[0]
            self.btn_pp.setChecked(True)
            self.videothread.start()

    def pp(self):
        if self.btn_pp.isChecked():
            self.videothread.play()
        else:
            self.videothread.pause()


class VideoThread(QThread):
    send_img = pyqtSignal(np.ndarray)

    def __init__(self):
        super().__init__()
        self.source = ''
        self.cond = QWaitCondition()
        self.status = True
        self.mutex = QMutex()

    def run(self):
        delay = 100
        cap = cv2.VideoCapture(self.source)
        if not cap.isOpened():
            print("Camera open failed!")
        while True:
            self.mutex.lock()
            ret, frame = cap.read()
            if not ret:
                break
            self.send_img.emit(frame)
            cv2.waitKey(delay)
            if not self.status:
                self.cond.wait(self.mutex)
            self.mutex.unlock()
        cap.release()
        cv2.destroyAllWindows()

    def play(self):
        self.status = True
        self.cond.wakeAll()

    def pause(self):
        self.status = False


ex = MyApp()
sys.exit(app.exec_())

우와아!

드디어 코드가 100줄을 넘어갔다.

코드를 해석해보자.

 

쓰레드의 재생/정지를 구현하기 위해서는 3가지가 필요하다.

1. QWaitCondition()

  쓰레드를 대기상태로 전환해준다.

2. QMutex()

  쓰레드를 잠궈버린다.

3. 상태변수 (여기서는 self.status)

 

mutex의 경우 비전공자 입장에서 쓰레드를 잠근다는 개념이 맞는지는 잘 모르겠다.

하지만 lock을 걸 경우 해당 쓰레드가 실행중일 때에는 어떠한 간섭도 당하지 않는다는 개념을

나는 '쓰레드를 잠근다' 라고 표현하겠다.

 

원리는 어렵지 않다.

쓰레드의 while문 안쪽의 self.status가 False 라면 쓰레드를 대기상태로 전환한다.

이때, 쓰레드가 잠겨있는 것이 아니라면 쓰레드가 강제로 해제되어 프로그램이 튕겨버리니 주의하자.

따라서 while문 안쪽과 끝에 mutex.lock()으로 잠궈주고 mutex.unlock()으로 해제시켰다.

 

CentWidget의 재생/정지 버튼을 아이콘으로 나타내고, 체크가능하도록 setCheckable(True)옵션을 주었다.

영상이 3초짜리이기 때문에 delay값을 0.1초로 주었다.

 

마지막으로 쓰레드는 terminate()함수로 종료가 가능하다.

영상이 끝나기 전에 곧바로 다른 영상을 확인하고 싶다면 일단 쓰레드를 종료해야 한다.

하지만 바로 종료하는 것은 위험하기 때문에 쓰레드를 중지시킨 뒤

QTest.qwait()함수를 이용하여 1초뒤에 종료하도록 했다.

버튼이 작아서 잘 안보이긴 한데 아무튼 잘 작동하고 있는 것을 알 수 있다.

 

한가지 문제가 있는데 이 코드를 그대로 사용하면 동영상이 끝났을 때는

다음 파일을 불러올 수 없을 것이다.

포스팅을 꼼꼼이 읽고 이해를 했다면 오류를 해결할 수 있을 것이다.

 

힌트를 주자면 쓰레드를 잠궜을 때에는 재사용하기 전 반드시 잠금을 해제시켜야한다.

(답은 밑의 속도구현코드에 숨겨둘테니 스크롤을 내리기 전에 풀고 내리길 권하고 싶다.)

 

이제 속도 조절기능으로 넘어가자.

 

유명한 동영상 플레이어인 미디어 플레이어나 곰플레이어 등은 2배속 0.8배속 등을 주고

나도 그렇게 구현은 가능하지만 기초부터 하기 위해 상대적 빠르기만 제공하기로 하자.

느림, 보통, 빠름 세 가지의 옵션을 구현해 볼 것이다.

import sys
from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
from PyQt5.QtCore import *
from PyQt5.QtTest import QTest
import cv2
import numpy as np

app = QApplication(sys.argv)
screen = app.primaryScreen()
size = screen.size()
width = size.width()
height = size.height()


class MyApp(QMainWindow):

    def __init__(self):
        super().__init__()
        self.initUI()

    def initUI(self):
        self.cent_widget = CentWidget()
        self.setCentralWidget(self.cent_widget)
        self.setWindowTitle('Video Player')
        self.setGeometry(width // 10, height // 10, width // 5 * 4, height // 5 * 4)
        menubar = self.menuBar()
        file = QMenu('파일', self)
        menubar.addMenu(file)
        load = QAction('불러오기', self)
        file.addAction(load)
        load.triggered.connect(self.cent_widget.load)
        self.show()


class CentWidget(QWidget):
    def __init__(self):
        super().__init__()
        self.videothread = VideoThread()
        base = QPixmap('base.JPG')
        self.lbl_img = QLabel()
        self.lbl_img.setScaledContents(True)
        self.lbl_img.setPixmap(base)
        icon_pp = QIcon()
        icon_pp.addPixmap(QPixmap("./icon/pause.png"), QIcon.Normal, QIcon.On)
        icon_pp.addPixmap(QPixmap("./icon/play.png"), QIcon.Active, QIcon.Off)
        icon_pp.addPixmap(QPixmap("./icon/pause.png"), QIcon.Active, QIcon.On)
        self.btn_pp = QPushButton()
        self.btn_pp.setCheckable(True)
        self.btn_pp.setIcon(icon_pp)
        self.btn_pp.clicked.connect(self.pp)
        speed_opt = QComboBox(self)
        [speed_opt.addItem(i) for i in ['빠르게', '보통', '느리게']]
        speed_opt.setCurrentIndex(1)
        speed_opt.activated.connect(self.videothread.speed)
        grid = QGridLayout()
        grid.addWidget(self.lbl_img, 0, 0, 19, 20)
        grid.addWidget(self.btn_pp, 19, 0, 1, 5)
        grid.addWidget(speed_opt, 19, 5, 1, 5)
        self.setLayout(grid)

        self.videothread.send_img.connect(self.show)

    def show(self, frame):
        frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
        img = QImage(frame.data, frame.shape[1], frame.shape[0], frame.shape[2] * frame.shape[1],
                     QImage.Format_RGB888)
        self.lbl_img.setPixmap(QPixmap.fromImage(img))

    def load(self):
        fname = QFileDialog.getOpenFileName(self, '동영상 선택하기', filter='*.mp4')
        if fname[0]:
            if self.videothread.isRunning():
                self.videothread.status = False
                QTest.qWait(1000)
                self.videothread.cap.release()
                self.videothread.status = True
                self.videothread.terminate()
            self.videothread.source = fname[0]
            self.btn_pp.setChecked(True)
            self.videothread.start()

    def pp(self):
        if self.btn_pp.isChecked():
            self.videothread.play()
        else:
            self.videothread.pause()


class VideoThread(QThread):
    send_img = pyqtSignal(np.ndarray)

    def __init__(self):
        super().__init__()
        self.source = ''
        self.cond = QWaitCondition()
        self.status = True
        self.mutex = QMutex()
        self.delay = 30

    def run(self):
        self.cap = cv2.VideoCapture(self.source)
        if not self.cap.isOpened():
            print("Camera open failed!")
        while True:
            self.mutex.lock()
            ret, frame = self.cap.read()
            if not ret:
                self.mutex.unlock()
                break
            self.send_img.emit(frame)
            cv2.waitKey(self.delay)
            if not self.status:
                self.cond.wait(self.mutex)
            self.mutex.unlock()
        self.cap.release()
        cv2.destroyAllWindows()

    def play(self):
        self.status = True
        self.cond.wakeAll()

    def pause(self):
        self.status = False

    def speed(self, e):
        if not e:
            self.delay = 1
        elif e == 1:
            self.delay = 30
        elif e == 2:
            self.delay = 100


ex = MyApp()
sys.exit(app.exec_())

speed_opt라는 콤보박스를 하나 만들었다.

이 콤보박스에 빠르게, 보통, 느리게 총 3가지 옵션을 구현하였다.

콤보박스가 활성화(activated)되면 쓰레드의 speed함수를 실행한다.

이제 이 프로그램은 동영상을 불러와서 재생과 일시정지를 할 수 있고, 속도를 조절할 수 있다.

이제 마지막으로 동영상 프레임위치 슬라이더가 남았다.

무릇 동영상 플레이어란 슬라이더가 존재해서 슬라이더를 조절함에 따라 동영상의 위치를 변환할 수 있다.

다음 포스팅에서는 슬라이더를 구현해 볼 것이다.

댓글