GARMINの.fitデータを動画に変換するツール rev1

2021/08/22 categories:TOOL| tags:Python|fitdecode|Open CV|GARMIN|

Pythonのfitdecodeを使ってGARMINの.fitデータを動画変換するツールを作成しました。

下記の記事で作成したプログラムにGUIを追加して、動画作成ツールとして使いやすくしてみました。

ファイルの読み込み

File openボタンをクリックしてファイルを選択すると.fitデータを読み込みます。読み込みが完了すると右側のビューにプレビュー画像が表示されます。

表示の変更

左側の数値を変更することで、表示する文字の位置やフォントなどを変更することが出来ます。

動画変換

Output videoボタンをクリックすると動画を作成して保存されます。変換時間はFPSと.fitのデータ量によって変化します。4時間記録した.fitデータを1FPSの動画として変換した場合、変換時間は15分程度でした。

変換した動画

変換した動画は以下の通りです。.fitデータは数秒間隔で記録されているため、以下の動画のように30FPSで変換した場合は、.fitデータを動画のFPSに合わせて線形補完しているため、FPSを高くした場合は変換時間が伸びます。FPSは1FPSで十分かと思います。

ソースコード

# -*- coding: utf-8 -*-
import cv2
import datetime
import fitdecode
import json
import sys
import numpy as np
from PyQt5 import QtWidgets, QtCore, QtGui

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self, parent=None):
        super(MainWindow, self).__init__(parent)
        self.resize(1750, 800)
        
        self.video_start_time = 0
        json_open = open('settings.json', mode='r',encoding='utf-8')
        self.settings = json.load(json_open)
        self.model = QtGui.QStandardItemModel()
        self.video_output = VideoOutput(self)

        self.button_file_open = QtWidgets.QPushButton('File open')
        self.button_output_video = QtWidgets.QPushButton('Output Video')
        self.progress_bar = QtWidgets.QProgressBar()
        self.graphics_view = QtWidgets.QGraphicsView()
        self.resolution_width = SpinBox('Output width', 0, 9999, 1280)
        self.resolution_height = SpinBox('Output height', 0, 9999, 720)
        self.fps = SpinBox('FPS', 0, 999, 1)
        self.font_scale_title = DoubleSpinBox('Font scale title', 0, 99, 1.3)
        self.font_scale_value = DoubleSpinBox('Font scale value', 0, 99, 1.8)
        self.font_thickness_inner = SpinBox('Font thickness inner', 0, 99, 2)
        self.font_thickness_outer = SpinBox('Font thickness outer', 0, 99, 6)
        self.title_offset = SpinBox('Title offset', -999, 999, -65)
        self.unit_offset = SpinBox('Unit offset', -999, 999, 200)
        self.table_view = QtWidgets.QTableView()

        widget0 = QtWidgets.QWidget()
        widget0.setLayout( QtWidgets.QVBoxLayout() )
        widget0.layout().addWidget(self.button_file_open)
        widget0.layout().addWidget(self.button_output_video)
        widget0.layout().addWidget(self.resolution_width)
        widget0.layout().addWidget(self.resolution_height)
        widget0.layout().addWidget(self.fps)
        widget0.layout().addWidget(self.font_scale_title)
        widget0.layout().addWidget(self.font_scale_value)
        widget0.layout().addWidget(self.font_thickness_inner)
        widget0.layout().addWidget(self.font_thickness_outer)
        widget0.layout().addWidget(self.title_offset)
        widget0.layout().addWidget(self.unit_offset)
        widget0.layout().addWidget(self.table_view)
        widget0.setMaximumWidth(400)
        widget0.setContentsMargins(2,2,2,2)
        
        self.setCentralWidget( QtWidgets.QWidget() )
        self.centralWidget().setLayout( QtWidgets.QHBoxLayout() )
        self.centralWidget().layout().addWidget(widget0)
        self.centralWidget().layout().addWidget(self.graphics_view)
        self.centralWidget().setContentsMargins(2,2,2,2)

        self.statusBar().addPermanentWidget(self.progress_bar)
        self.statusBar().showMessage('Hello!')

        self.table_view.setModel(self.model)
        self.table_view.horizontalHeader().setStretchLastSection(True)

        self.model.setVerticalHeaderLabels([ d[0] for d in self.settings['datas'][1:] ])
        self.model.setHorizontalHeaderLabels( self.settings['datas'][0][1:] )

        for row in range( self.model.rowCount() ):
            item = QtGui.QStandardItem()
            item.setCheckable(True)
            self.model.setItem(row, 0, item)
        
        for row, row_data in enumerate(self.settings['datas'][1:]):
            for column, data in enumerate(row_data[1:]):
                index = self.model.index(row, column)
                if column == 0:
                    item = self.model.itemFromIndex(index)
                    item.setCheckState(data)
                else:
                    self.model.setData(index, data)
        
        self.table_view.setColumnWidth(0, 50)
        self.table_view.setColumnWidth(1, 50)
        self.table_view.setColumnWidth(2, 50)
        self.table_view.setColumnWidth(3, 50)
        
        self.button_file_open.clicked.connect(self.file_open)
        self.button_output_video.clicked.connect(self.output_video)
        self.video_output.nextSignal.connect(self.video_output_next)
        self.model.dataChanged.connect(self.model_changed)
        self.resolution_height.spin_box.valueChanged.connect( lambda : self.model_changed(0) )
        self.resolution_width.spin_box.valueChanged.connect( lambda : self.model_changed(0) )
        self.fps.spin_box.valueChanged.connect( lambda : self.model_changed(0) )
        self.font_scale_title.spin_box.valueChanged.connect( lambda : self.model_changed(0) )
        self.font_scale_value.spin_box.valueChanged.connect( lambda : self.model_changed(0) )
        self.font_thickness_inner.spin_box.valueChanged.connect( lambda : self.model_changed(0) )
        self.font_thickness_outer.spin_box.valueChanged.connect( lambda : self.model_changed(0) )
        self.title_offset.spin_box.valueChanged.connect( lambda : self.model_changed(0) )
        self.unit_offset.spin_box.valueChanged.connect( lambda : self.model_changed(0) )
        self.video_output.finishSignal.connect( lambda : self.statusBar().showMessage( self.statusBar().currentMessage() + '   Finished') )

    def model_changed(self, i):
        try:
            data = {}
            for row in range( self.model.rowCount() ):

                d = {}
                for column in range( self.model.columnCount() ):
                    index = self.model.index(row, column)
                    item = self.model.itemFromIndex(index)
                    horizontal_header = self.model.horizontalHeaderItem(column).text()
                    if horizontal_header == 'X' or horizontal_header == 'Y':
                        d[horizontal_header] = int(item.text())
                    else:
                        d[horizontal_header] = item.text()

                vertical_header = self.model.verticalHeaderItem(row).text()
                data[vertical_header] = d
            
            self.video_output.datas = data

            self.video_output.setting['width'] = self.resolution_width.spin_box.value()
            self.video_output.setting['height'] = self.resolution_height.spin_box.value()
            self.video_output.setting['fps'] = self.fps.spin_box.value()
            self.video_output.setting['font_scale_title'] = self.font_scale_title.spin_box.value()
            self.video_output.setting['font_scale_value'] = self.font_scale_value.spin_box.value()
            self.video_output.setting['font_thickness_inner'] = self.font_thickness_inner.spin_box.value()
            self.video_output.setting['font_thickness_outer'] = self.font_thickness_outer.spin_box.value()
            self.video_output.setting['title_offset'] = self.title_offset.spin_box.value()
            self.video_output.setting['unit_offset'] = self.unit_offset.spin_box.value()

            record = self.video_output.records[0]
            frame = self.video_output.image(record)
            rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB).data
            image = QtGui.QImage(rgb, self.video_output.setting['width'], self.video_output.setting['height'], self.video_output.setting['width']*3, QtGui.QImage.Format_RGB888)
            pixmap = QtGui.QPixmap.fromImage(image)
            self.graphics_view.scene().clear()
            self.graphics_view.scene().addPixmap(pixmap)
            self.statusBar().showMessage('Updated')
        except:
            self.statusBar().showMessage('Error')
        
    def closeEvent(self, event):
        if self.video_output.isRunning():
            self.video_output.terminate()
            self.video_output.video.release()

    def file_open(self):
        filename, filter = QtWidgets.QFileDialog.getOpenFileName(None, 'Open file', '', 'GARMIN FIT Data(*.fit)')
        if filename == '':
            return

        records = []
        with fitdecode.FitReader(filename, processor=fitdecode.StandardUnitsDataProcessor()) as fit:
            for frame in fit:
                if not isinstance(frame, fitdecode.FitDataMessage):
                    continue
                if not frame.name == 'record':
                    continue
                records.append(frame)
        
        count = len(records) - 1
        self.progress_bar.setValue(0)
        self.progress_bar.setMaximum(count)
        self.statusBar().showMessage( 'output : 0/' + str(count) )

        self.video_output.records = records
        self.video_output.start_time = records[0].get_field('timestamp').value.timestamp()

        self.graphics_view.setScene( 
            QtWidgets.QGraphicsScene(0, 0, self.video_output.setting['width'], self.video_output.setting['height'], self.graphics_view) 
        )

        self.model_changed(0)

    def output_video(self):
        filename, filter = QtWidgets.QFileDialog.getSaveFileName(None, 'Save file', '', 'Video(*.mp4)')
        if filename == '':
            return

        self.video_output.filename = filename
        self.video_output.start()
        self.video_start_time = datetime.datetime.now()

    def video_output_next(self):
        count = self.progress_bar.value() + 1
        self.progress_bar.setValue(count)
        elapsed_time = (datetime.datetime.now() - self.video_start_time).total_seconds()

        frame_text = 'output : ' + str(count) + '/' + str(self.progress_bar.maximum())
        elapsed_time_text = 'elapsed time ' + '{0:02d}:{1:02d}:{2:02d}'.format(int(elapsed_time//3600), int(elapsed_time%3600//60), int(elapsed_time%60))
        self.statusBar().showMessage( frame_text + '   ' + elapsed_time_text )

class SpinBox(QtWidgets.QWidget):
    def __init__(self, text, min, max, value):
        super(SpinBox, self).__init__()
        self.label =  QtWidgets.QLabel()
        self.label.setText(text)
        self.spin_box = QtWidgets.QSpinBox()
        self.spin_box.setMinimum(min)
        self.spin_box.setMaximum(max)
        self.spin_box.setValue(value)
        self.setLayout( QtWidgets.QHBoxLayout() )
        self.layout().addWidget(self.label)
        self.layout().addWidget(self.spin_box)
        self.setContentsMargins(2,2,2,2)
        self.layout().setContentsMargins(2,2,2,2)

class DoubleSpinBox(QtWidgets.QWidget):
    def __init__(self, text, min, max, value):
        super(DoubleSpinBox, self).__init__()
        self.label =  QtWidgets.QLabel()
        self.label.setText(text)
        self.spin_box = QtWidgets.QDoubleSpinBox()
        self.spin_box.setMinimum(min)
        self.spin_box.setMaximum(max)
        self.spin_box.setValue(value)
        self.spin_box.setSingleStep(0.1)
        self.setLayout( QtWidgets.QHBoxLayout() )
        self.layout().addWidget(self.label)
        self.layout().addWidget(self.spin_box)
        self.setContentsMargins(2,2,2,2)
        self.layout().setContentsMargins(2,2,2,2)

class MyRecord():
    class Field():
        def __init__(self, value, unit):
            self.value = value
            self.unit = unit

    def __init__(self, keys, values, units):
        self.fields = { k : self.Field(v, u) for k, v, u in zip(keys, values, units) }

    def get_field(self, field_name_or_num):
        return self.fields[field_name_or_num]

    def has_field(self, field_name_or_num):
        return field_name_or_num in self.fields

class VideoOutput(QtCore.QThread):
    nextSignal = QtCore.pyqtSignal()
    finishSignal = QtCore.pyqtSignal()

    def __init__(self, parent):
        super(VideoOutput, self).__init__(parent)
        self.records = []
        self.datas = {}
        self.codec = cv2.VideoWriter_fourcc(*'mp4v')
        self.filename = 'output.mp4'
        self.start_time = 0
        self.setting = { 
            'width':1280, 'height':720, 'fps':1.0, 'font_scale_title':1.3, 'font_scale_value':1.8, 
            'font_thickness_inner':2, 'font_thickness_outer':6, 'font_face':cv2.FONT_HERSHEY_DUPLEX,
            'font_color_inner':(255,255,255), 'font_color_outer':(0,0,0), 'font_line_type':cv2.LINE_AA,
            'backgound_color':(0, 255, 0), 'title_offset':-65, 'unit_offset':200
        }

    def image(self, record):
        
        image = np.full( (self.setting['height'], self.setting['width'], 3), (0, 255, 0), dtype=np.uint8 )
        font_face = self.setting['font_face']
        font_scale_title = self.setting['font_scale_title']
        font_scale_value = self.setting['font_scale_value']
        font_color_inner = self.setting['font_color_inner']
        font_color_outer = self.setting['font_color_outer']
        font_thickness_outer = self.setting['font_thickness_outer']
        font_thickness_inner = self.setting['font_thickness_inner']
        font_line_type = self.setting['font_line_type']
        title_offset = self.setting['title_offset']
        unit_offset = self.setting['unit_offset']

        for key in self.datas:
            data = self.datas[key]
            if record.has_field( data['Key'] ):
                value = record.get_field( data['Key'] ).value
            else:
                if key == 'Time' or key == 'Timestamp':
                    value = datetime.datetime(1900, 1, 1, 0, 0, 0, 0)
                else:
                    value = 0

            x, y, value, unit = data['X'], data['Y'], data['Format'].format(value), data['Unit']

            cv2.putText(image, key, (x,y+title_offset), font_face, font_scale_title, font_color_outer, font_thickness_outer, font_line_type)
            cv2.putText(image, key, (x,y+title_offset), font_face, font_scale_title, font_color_inner, font_thickness_inner, font_line_type)
            cv2.putText(image, value,            (x,y), font_face, font_scale_value, font_color_outer, font_thickness_outer, font_line_type)
            cv2.putText(image, value,            (x,y), font_face, font_scale_value, font_color_inner, font_thickness_inner, font_line_type)
            cv2.putText(image, unit, (x+unit_offset,y), font_face, font_scale_title, font_color_outer, font_thickness_outer, font_line_type)
            cv2.putText(image, unit, (x+unit_offset,y), font_face, font_scale_title, font_color_inner, font_thickness_inner, font_line_type)

        return image

    def run(self):

        height, width, fps = self.setting['height'], self.setting['width'], self.setting['fps']
        video = cv2.VideoWriter( self.filename, self.codec, fps, (width, height) )

        keys  = [ self.datas[k]['Key']  for k in self.datas ]
        units = [ self.datas[k]['Unit'] for k in self.datas ]
        self.start_time = self.records[0].get_field('timestamp').value.timestamp()
        last_frame = None

        for i, record1 in enumerate(self.records[1:]):
            record0 = self.records[i]
            t0 = record0.get_field('timestamp').value.timestamp()
            t1 = record1.get_field('timestamp').value.timestamp()
            frame_count = int( (t1 - t0) * fps )
            
            arrays = {}
            arrays['timestamp'] = np.linspace( t0, t1, frame_count + 1 )
            arrays['timestamp'] = [ datetime.datetime.fromtimestamp(t) for t in arrays['timestamp'] ]
            arrays['time'] = np.linspace( t0 - self.start_time, t1 - self.start_time, frame_count + 1 )
            arrays['time'] = [ datetime.datetime.fromtimestamp(t) for t in arrays['time'] ]

            for key in keys:
                if key == 'timestamp':
                    continue
                if not record0.has_field(key):
                    continue
                if not record1.has_field(key):
                    continue
                arrays[key] = np.linspace( record0.get_field(key).value, record1.get_field(key).value, frame_count + 1 )

            arrays = np.array([ arrays.get(k) for k in keys]).T

            for array in arrays[:-1]:
                try:
                    record2 = MyRecord(keys, array, units)
                    image = self.image(record2)
                    video.write(image)
                    last_frame = image
                except:
                    video.write(last_frame)

            self.nextSignal.emit()

        video.release()
        self.finishSignal.emit()

if __name__ == '__main__':
    app = QtWidgets.QApplication(sys.argv)
    window = MainWindow()
    window.show()
    app.exec()

Share post

Related Posts

Comments

comments powered by Disqus