OpenCVで動画を読み込みPyQt5/PyQtに表示してみた(車輪の再開発) 

[こちらに](https://qiita.com/cartooh/items/e96ed68f8ce39601cc08)更新版作成(2025.2.26)

# フレーム単位/秒単位で操作できるプレーヤー

## 目的
下記目的に合致するものがなかったので、都合のいいように作りました。

- OpenCVで読み込んだ動画を、フレーム単位で別のデータと同期を取るため
- OpenCVのフレーム単位で、コマ送り・戻しをしたい
- フレーム番号指定で移動
- 秒単位で移動(浮動小数点OK)
- 自動連続コマ送り(なんちゃって再生機能)
- QtのWidgetとして再利用可能

## 実装
```python:VideoPlayerWidget.py
import cv2
import sys
import argparse
from PyQt5 import QtWidgets, QtCore, QtGui

class GraphicsView(QtWidgets.QGraphicsView):
    areaSelected = QtCore.pyqtSignal(QtCore.QRectF)

    def __init__(self, *argv, **keywords):
        super(GraphicsView, self).__init__(*argv, **keywords)
        
        self._numScheduledScalings = 0
        self._rectF = QtCore.QRectF(0.0, 0.0, 0.0, 0.0)
    
    def wheelEvent(self, event):
        numDegrees = event.angleDelta().y() / 8
        numSteps = numDegrees / 15
        self._numScheduledScalings += numSteps
        if self._numScheduledScalings * numSteps < 0:
            self._numScheduledScalings = numSteps
        anim = QtCore.QTimeLine(350, self)
        anim.setUpdateInterval(20)
        anim.valueChanged.connect(self._scalingTime)
        anim.finished.connect(self._animFinished)
        anim.start()

    def _scalingTime(self, x):
        factor = 1.0 + float(self._numScheduledScalings) / 300.0
        self.scale(factor, factor)

    def _animFinished(self):
        if self._numScheduledScalings > 0:
            self._numScheduledScalings -= 1
        else:
            self._numScheduledScalings += 1

    def mousePressEvent(self, event):
        
        if event.button() == QtCore.Qt.MidButton:
            self.setDragMode(QtWidgets.QGraphicsView.ScrollHandDrag)

            event = QtGui.QMouseEvent(
                QtCore.QEvent.GraphicsSceneDragMove, 
                event.pos(), 
                QtCore.Qt.MouseButton.LeftButton, 
                QtCore.Qt.MouseButton.LeftButton, 
                QtCore.Qt.KeyboardModifier.NoModifier
            )

        elif event.button() == QtCore.Qt.LeftButton:
            self.setDragMode(QtWidgets.QGraphicsView.RubberBandDrag)
        
        point = self.mapToScene(event.pos())
        self._rectF = QtCore.QRectF(point, point)
        QtWidgets.QGraphicsView.mousePressEvent(self, event)
   
    def mouseReleaseEvent(self, event):
        QtWidgets.QGraphicsView.mouseReleaseEvent(self, event)
        self.setDragMode(QtWidgets.QGraphicsView.NoDrag)
        if event.button() == QtCore.Qt.LeftButton:
            point = self.mapToScene(event.pos())
            self._rectF.setBottomRight(point)
            self.areaSelected.emit(self._rectF)


class VideoPlayerWidget(QtWidgets.QWidget):
    areaSelected = QtCore.pyqtSignal(QtCore.QRectF)
    errorOccurred = QtCore.pyqtSignal(str)
    videoDraw = QtCore.pyqtSignal(float)
    
    def __init__(self):
        super().__init__()
        self._initUI()
        
        # 再生ボタン用
        self._timeLine = QtCore.QTimeLine()
        self._timeLine.valueChanged.connect(self._nextFrameVideo)
        self._timeLine.finished.connect(self._finish)
        # フレーム数を指定して再生すると遅いので、使わない
        # self._timeLine.frameChanged.connect(self._updateFrame)
        # self._timeLine.setEasingCurve(QtCore.QEasingCurve.Linear)
        
        # 動画の情報
        self._video = None
        self._videoFPS = 0
        self._videoWidth = 0
        self._videoHeight = 0
        
        # 動画に重畳表示する際、再描画用のキャッシュ
        self._cache_frame = None
    
    def _initUI(self):
        # openCVの映像を表示
        self._graphicsView = GraphicsView()
        self._graphicsView.setMinimumSize(640, 480)
        self._graphicsView.areaSelected.connect(lambda rectF: self.areaSelected.emit(rectF))
        
        # 再生ボタン
        self._playButton = QtWidgets.QPushButton()
        self._playButton.setEnabled(False)
        self._playButton.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_MediaPlay))
        self._playButton.clicked.connect(self._play)
        
        # 1フレーム送るボタン
        nextBtn = QtWidgets.QPushButton("≫")
        nextBtn.setEnabled(False)
        nextBtn.setToolTip("Next Frame")
        nextBtn.setStatusTip("Next Frame")
        nextBtn.setFixedSize(24, 24)
        nextBtn.setShortcut(QtCore.Qt.Key_Right)
        nextBtn.clicked.connect(self._nextFrameVideo)
        
        # 1フレーム戻るボタン
        prevBtn = QtWidgets.QPushButton("≪")
        prevBtn.setEnabled(False)
        prevBtn.setToolTip("Previous Frame")
        prevBtn.setStatusTip("Previous Frame")
        prevBtn.setFixedSize(24, 24)
        prevBtn.setShortcut(QtCore.Qt.Key_Left)
        prevBtn.clicked.connect(lambda: self._movePositionSlider(-1))
        
        # 制御ボタン。後でlayoutに追加する用
        ctrlBtn = [self._playButton, prevBtn, nextBtn]
        
        # 送り戻りボタン生成
        for s in [-1, +1, -5, +5]:
            btn = QtWidgets.QPushButton(f"{s:+}s")
            btn.setEnabled(False)
            btn.setToolTip(f"Seek approx. {s:+}s")
            btn.setStatusTip(f"Seek approx. {s:+}s")
            btn.setFixedSize(30, 24)
            btn.clicked.connect(lambda *, s=s: self._movePositionSlider(s * self._videoFPS))
            ctrlBtn += [btn]
        
        # enable/disableを切り替えるため
        self._controlButtons = ctrlBtn
        
        # 現在のフレーム番号表示用
        curFrame = QtWidgets.QLineEdit()
        curFrame.setFixedWidth(70)
        curFrame.setValidator(QtGui.QIntValidator())
        curFrame.setAlignment(QtCore.Qt.AlignRight)
        curFrame.editingFinished.connect(lambda: self._seekPositionSlider(curFrame.text()))
        
        # 現在の経過秒数を表示用
        curSec = QtWidgets.QLineEdit()
        curSec.setFixedWidth(70)
        curSec.setValidator(QtGui.QDoubleValidator(0.00, 1000.00, 3))
        curSec.setAlignment(QtCore.Qt.AlignRight)
        curSec.editingFinished.connect(lambda: self.setCurrentVideoSec(curSec.text()))
        
        # 経過秒数の別表示(コピー用)
        curTime = QtWidgets.QLineEdit()
        # curTime.setReadOnly(True)
        curTime.setFixedWidth(70)
        
        # 全フレーム数、長さ(秒)を表示する用
        endInfo = QtWidgets.QLineEdit()
        endInfo.setFixedWidth(120)
        
        self._curTimeEdit  = curTime
        self._curSecEdit  = curSec
        self._curFrameEdit = curFrame
        self._endInfoEdit  = endInfo
        
        self._positionSlider = QtWidgets.QSlider(QtCore.Qt.Horizontal)
        self._positionSlider.setRange(0, 0)
        self._positionSlider.sliderMoved.connect(self._setPosition)
        
        # 動画を開くボタン
        openButton = QtWidgets.QPushButton("Open")
        openButton.setToolTip("Open Video File")
        openButton.setStatusTip("Open Video File")
        openButton.setFixedSize(70, 24)
        openButton.clicked.connect(self.openVideoFile)
        
        # Create layouts to place inside widget
        self.ctrlLayout = QtWidgets.QHBoxLayout()
        self.ctrlLayout.setContentsMargins(0, 0, 0, 0)
        for b in ctrlBtn:
            self.ctrlLayout.addWidget(b)

        self.ctrlLayout.addWidget(curFrame)
        self.ctrlLayout.addWidget(curSec)
        self.ctrlLayout.addWidget(curTime)
        self.ctrlLayout.addWidget(endInfo)
        self.ctrlLayout.addStretch()
        self.ctrlLayout.addWidget(openButton)
        
        self.baseLayout = QtWidgets.QVBoxLayout()
        self.baseLayout.addWidget(self._graphicsView)
        self.baseLayout.addLayout(self.ctrlLayout)
        self.baseLayout.addWidget(self._positionSlider)
        
        self.setLayout(self.baseLayout)

    def _initVideo(self):
        if self._video is None:
            return
        
        frame_rate = self._video.get(cv2.CAP_PROP_FPS)
        frame_count = int(self._video.get(cv2.CAP_PROP_FRAME_COUNT))
        frame_time = 1000 / frame_rate
        video_time = frame_count / frame_rate * 1000
        self._pos = 0
        self._sec = 0
        self._videoFPS = frame_rate
        self._videoWidth = int(self._video.get(cv2.CAP_PROP_FRAME_WIDTH))
        self._videoHeight = int(self._video.get(cv2.CAP_PROP_FRAME_HEIGHT))
        
        self._endInfoEdit.setText(f"{frame_count} frame; {video_time/1000:.3f}s")
        
        self._positionSlider.setMaximum(frame_count)
        self._setPositionSliderValueWithOutSignals(0)
        self._timeLine.setDuration(int(video_time))
        self._timeLine.setUpdateInterval(int(frame_time))
        #self._timeLine.setFrameRange(0, frame_count) # フレーム数を指定して再生すると遅いので、使わない
        
        
        self._graphicsView.setScene( 
            QtWidgets.QGraphicsScene(0, 0, self._videoWidth, self._videoHeight, self._graphicsView) 
        )
        self._graphicsView.scale(0.5, 0.5)
        
        self._updateVideo()
    
    def __setVideoPosFrames(self, pos):
        if self._video is None:
            return
        self._video.set(cv2.CAP_PROP_POS_FRAMES, pos)
    
    def __getVideoPosFrames(self):
        if self._video is None:
            return None
        return int(self._video.get(cv2.CAP_PROP_POS_FRAMES))
    
    def __getVideoPosMSec(self):
        if self._video is None:
            return None
        return self._video.get(cv2.CAP_PROP_POS_MSEC)
    
    def __readVideo(self):
        ret, frame = self._video.read()
        if ret:
            return frame
        else:
            return None
    
    def __drawImage(self, frame):
        rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB).data
        image = QtGui.QImage(rgb, self._videoWidth, self._videoHeight, self._videoWidth*3, QtGui.QImage.Format_RGB888)
        pixmap = QtGui.QPixmap.fromImage(image)
        self._graphicsView.scene().clear()
        self._graphicsView.scene().addPixmap(pixmap)
    
    def _convImage(self, frame):
        return frame
    
    def _redrawVideo(self):
        if self._cache_frame is None:
            return
        
        self.__drawImage(self._convImage(self._cache_frame))
    
    def __drawVideo(self):
        self._cache_frame = self.__readVideo()
        self._redrawVideo()

    @property
    def pos(self):
        return self._pos
    
    @property
    def sec(self):
        return self._sec
    
    def _setPos(self, pos):
        self._pos = pos
        self._sec = self._pos / self._videoFPS # _setPos は 再生中しか呼ばれないので、0割は無視
        
        self._displayCurrentInfo()
    
    def _displayCurrentInfo(self):
        self._curFrameEdit.setText(str(self._pos))
        self._curSecEdit.setText(f"{self._sec:.3f}")
        
        min, sec = divmod(self._sec, 60)
        hour, min = divmod(min, 60)
        self._curTimeEdit.setText(f"{hour:02.0f}:{min:02.0f}:{sec:06.3f}")
        
    
    def _updateVideo(self, *, pos=None):
        if self._video is None:
            return
        
        if pos is None:
            pos = self.__getVideoPosFrames()
            val = self._positionSlider.value()
            if pos != val:
                print(f"_updateVideo pos != val: {pos=}, {val=}")
        else:
            self.__setVideoPosFrames(pos)
            pos = self.__getVideoPosFrames()
        
        self.__drawVideo()
        self._setPos(pos)
        self.videoDraw.emit(self._sec)
        
    
    def _seekVideo(self, pos):
        if self._video is None:
            return
        
        self._updateVideo(pos=pos)
        #print(pos, self.__getVideoPosFrames(), self._curSecEdit.text(), self.__getVideoPosMSec())
    
    def _setPositionSliderValueWithOutSignals(self, val):
        self._positionSlider.blockSignals(True)
        self._positionSlider.setValue(val)
        self._positionSlider.blockSignals(False)
    
    
    def _updatePositionSlider(self):
        val = self.__getVideoPosFrames()
        self._setPositionSliderValueWithOutSignals(val)
    
    def _nextFrameVideo(self, *args):
        self._updatePositionSlider()
        self._updateVideo()
    
    def _seekPositionSlider(self, seek):
        seek = int(seek)
        self._seekVideo(seek)
        self._positionSlider.setValue(seek)
    
    def _movePositionSlider(self, move):
        val = round(self._positionSlider.value() + move)
        self._seekPositionSlider(val)
    
    
    
    
    # フレーム数を指定して再生すると遅いので、使わない
    # def _updateFrame(self, x):
    #     print(f"_updateFrame: {x}")
    #     self._seekVideo(x)
    #     self._positionSlider.blockSignals(True)
    #     self._positionSlider.setValue(x)
    #     self._positionSlider.blockSignals(False)
    
    def _finish(self):
        #print(f"_finish")
        self._playButton.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_MediaPlay))
    
    def setVideoFile(self, fileName):
        if self._video is not None:
            self._video.release()
            self._video = None
        
        self._video = cv2.VideoCapture(fileName)
        if self._video.isOpened():
            for b in self._controlButtons:
                b.setEnabled(True)
            self._initVideo()
        else:
            for b in self._controlButtons:
                b.setEnabled(False)
            self.errorOccurred.emit(f"can't open error: {fileName}")
    
    def openVideoFile(self):
        fileName, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Open Movie")
        
        if fileName != '':
            self.setVideoFile(fileName)
    
    def _play(self):
        if self._timeLine.state() == QtCore.QTimeLine.NotRunning:
            self._timeLine.start()
            self._playButton.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_MediaPause))
        elif self._timeLine.state() == QtCore.QTimeLine.Paused:
            self._timeLine.setPaused(False)
            self._playButton.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_MediaPause))
        elif self._timeLine.state() == QtCore.QTimeLine.Running:
            self._timeLine.setPaused(True)
            self._playButton.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_MediaPlay))
        else:
            pass
        
    def _setPosition(self, position):
        self._seekVideo(position)
    
    def setCurrentVideoSec(self, sec):
        self._seekPositionSlider(round(float(sec) * self._videoFPS))



def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("-a", "--avi", help="動画ファイル")
    args = parser.parse_args()
    
    app = QtWidgets.QApplication(sys.argv)
    
    window = VideoPlayerWidget()
    window.show()
    if args.avi:
        print(args.avi)
        window.setVideoFile(args.avi)

    app.exec()

if __name__ == '__main__':
    main()



```

## 制約
- 再生ボタンは、コマ送りボタンを連打しているだけです
- 途中で止めて、再度再生した際、最後に行っても規定時間、押し続けます
- 描画に時間がかかると、最後まで行かず、停止します

自分の用途では問題ないので、そのままです。

## 参考サイト
 - [OpenCVで動画を読み込みPyQt5に表示してみた](https://ymt-lab.com/post/2020/pyqt5-opencv-play-video/)
 - [Pythonで作るシンプル画像ビューワ](https://ymt-lab.com/post/2020/simple-image-viewer/)