├── apps ├── splashlogo.png ├── barbellcvicon.ico ├── splashscreen.py ├── documentation.py ├── barbellcvsplash.py ├── barbellcvlog.ui └── barbellcvlog.py ├── docs ├── screenshots │ ├── failure_criteria.png │ ├── front_squat_failed.png │ ├── front_squat_corrected.png │ └── front_squat_false_rep.png ├── setup.html ├── accuracy_considerations.html ├── logging_sets.html ├── editing_sets.html ├── requirements.html └── documentation.html ├── requirements.txt ├── resources ├── settings.json └── lifts.json ├── setup.py ├── utils ├── webcam.py ├── database.py └── analyze.py ├── main.pyw ├── .gitignore └── README.md /apps/splashlogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tlancon/barbellcv/HEAD/apps/splashlogo.png -------------------------------------------------------------------------------- /apps/barbellcvicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tlancon/barbellcv/HEAD/apps/barbellcvicon.ico -------------------------------------------------------------------------------- /docs/screenshots/failure_criteria.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tlancon/barbellcv/HEAD/docs/screenshots/failure_criteria.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyQt5 2 | PyQtWebEngine 3 | QDarkStyle 4 | numpy 5 | opencv-python 6 | pandas 7 | scipy 8 | pyqtgraph>=0.11.0rc0 -------------------------------------------------------------------------------- /docs/screenshots/front_squat_failed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tlancon/barbellcv/HEAD/docs/screenshots/front_squat_failed.png -------------------------------------------------------------------------------- /docs/screenshots/front_squat_corrected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tlancon/barbellcv/HEAD/docs/screenshots/front_squat_corrected.png -------------------------------------------------------------------------------- /docs/screenshots/front_squat_false_rep.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tlancon/barbellcv/HEAD/docs/screenshots/front_squat_false_rep.png -------------------------------------------------------------------------------- /resources/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "camera": 0, 3 | "rotation": 0, 4 | "colors": { 5 | "min_hue": 0, 6 | "max_hue": 180, 7 | "min_saturation": 0, 8 | "max_saturation": 255, 9 | "min_value": 0, 10 | "max_value": 255 11 | }, 12 | "diameter": 83, 13 | "name": "Lifter" 14 | } -------------------------------------------------------------------------------- /apps/splashscreen.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtWidgets import QWidget, QLabel 2 | from PyQt5.QtCore import Qt, QTimer 3 | from PyQt5.QtGui import QPixmap 4 | 5 | # Thank you Jie Jenn https://www.youtube.com/watch?v=mYPNHoPwIJI 6 | 7 | 8 | class SplashWindow(QWidget): 9 | def __init__(self): 10 | super().__init__() 11 | self.setFixedSize(300, 300) 12 | self.setWindowFlags(Qt.WindowStaysOnTopHint| Qt.FramelessWindowHint) 13 | 14 | self.label_animation = QLabel(self) 15 | 16 | self.logo = QPixmap('./apps/splashlogo.png') 17 | self.label_animation.setPixmap(self.logo) 18 | 19 | timer = QTimer(self) 20 | timer.singleShot(4500, self.close) 21 | self.show() 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open('requirements.txt', 'r') as fc: 4 | requirements = [line.strip() for line in fc] 5 | 6 | setuptools.setup( 7 | name='barbellcv', 8 | version='0.3.0', 9 | author='Trevor Lancon', 10 | description='Interactive velocity-based tracking for barbell movements.', 11 | long_description='Track the path, velocity, power output, and time to complete reps for barbell movements using' 12 | 'only a laptop and a webcam.', 13 | packages=setuptools.find_packages(), 14 | classifiers=[ 15 | "Development Status :: 3 - Alpha", 16 | "Programming Language :: Python :: 3", 17 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 18 | "Operating System :: OS Independent", 19 | ], 20 | python_requires='>3.6', 21 | install_requires=requirements 22 | ) 23 | -------------------------------------------------------------------------------- /apps/documentation.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtWidgets import QWidget, QVBoxLayout 2 | from PyQt5.QtWebEngineWidgets import QWebEngineView 3 | from PyQt5.QtCore import QUrl 4 | 5 | # shamelessly ripped off from: http://zetcode.com/pyqt/qwebengineview/ 6 | 7 | 8 | class Documentation(QWidget): 9 | def __init__(self): 10 | super().__init__() 11 | self.init_UI() 12 | 13 | def init_UI(self): 14 | vbox = QVBoxLayout(self) 15 | self.webEngineView = QWebEngineView() 16 | self.load_page() 17 | vbox.addWidget(self.webEngineView) 18 | self.setLayout(vbox) 19 | self.setGeometry(100, 100, 1000, 800) 20 | self.setWindowTitle('barbellcv Documentation') 21 | self.show() 22 | 23 | def load_page(self): 24 | with open('./docs/documentation.html', 'r') as f: 25 | html = f.read() 26 | self.webEngineView.setHtml(html, baseUrl=QUrl('file:///docs/')) 27 | -------------------------------------------------------------------------------- /apps/barbellcvsplash.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import qdarkstyle 4 | from PyQt5 import QtCore, QtGui, QtWidgets, uic 5 | 6 | qtCreatorFile = os.path.abspath('./apps/barbellcvsplash.ui') 7 | iconFile = os.path.abspath('./apps/barbellcvicon.ico') 8 | Ui_SplashScreen, QtBaseClass = uic.loadUiType(qtCreatorFile) 9 | 10 | 11 | class SplashThread(QtCore.QThread): 12 | def __init__(self): 13 | QtCore.QThread.__init__(self) 14 | 15 | def run(self): 16 | app = QtWidgets.QApplication(sys.argv) 17 | window = BarbellCVSplash() 18 | app.setStyleSheet(qdarkstyle.load_stylesheet_pyqt5()) 19 | window.show() 20 | app.exec_() 21 | 22 | 23 | class BarbellCVSplash(QtWidgets.QMainWindow, Ui_SplashScreen): 24 | def __init__(self): 25 | QtWidgets.QMainWindow.__init__(self) 26 | self.setupUi(self) 27 | self.gif = QtGui.QMovie(os.path.abspath('./apps/splashanimation.gif')) 28 | self.gif.finished.connect(self.hide) 29 | self.labelSplash.setMovie(self.gif) 30 | self.gif.start() 31 | -------------------------------------------------------------------------------- /docs/setup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | barbellcv Setup 6 | 19 | 20 | 21 | 22 |

|||---barbell-cv---|||

23 | 24 |

Table of Contents

25 | 26 | Requirements
27 | Setup
28 | Logging Sets
29 | Editing Sets
30 | Accuracy Considerations
31 | Back to Main
32 | 33 |

Setup

34 | 35 |

36 | Coming soon after more testing. It's summer in Texas and like 235203845109857 degrees in my garage so I don't 37 | currently train with the garage door closed (seriously it's an oven) and can't do some decent testing. 38 |

39 | 40 | 41 | -------------------------------------------------------------------------------- /docs/accuracy_considerations.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | barbellcv Accuracy Considerations 6 | 19 | 20 | 21 | 22 |

|||---barbell-cv---|||

23 | 24 |

Table of Contents

25 | 26 | Requirements
27 | Setup
28 | Logging Sets
29 | Editing Sets
30 | Accuracy Considerations
31 | Back to Main
32 | 33 |

Accuracy Considerations

34 | 35 |

36 | Coming soon after more testing. It's summer in Texas and like 235203845109857 degrees in my garage so I don't 37 | currently train with the garage door closed (seriously it's an oven) and can't do some decent testing. 38 |

39 | 40 | 41 | -------------------------------------------------------------------------------- /docs/logging_sets.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | barbellcv Logging Sets 6 | 19 | 20 | 21 | 22 |

|||---barbell-cv---|||

23 | 24 |

Table of Contents

25 | 26 | Requirements
27 | Setup
28 | Logging Sets
29 | Editing Sets
30 | Accuracy Considerations
31 | Back to Main
32 | 33 |

Logging Sets

34 | 35 |

36 | Reps are classified based on their pass/fail criteria: 37 |

38 | Pass/fail criteria for reps. 39 |

40 | If a rep is completed but fails the success criterion, it is highlighted in red: 41 |

42 | Failed front squat. 43 | 44 | 45 | -------------------------------------------------------------------------------- /docs/editing_sets.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | barbellcv Editing Sets 6 | 19 | 20 | 21 | 22 |

|||---barbell-cv---|||

23 | 24 |

Table of Contents

25 | 26 | Requirements
27 | Setup
28 | Logging Sets
29 | Editing Sets
30 | Accuracy Considerations
31 | Back to Main
32 | 33 |

Editing Sets

34 | 35 |

36 | Here we see an example of a false front squat rep from the unracking of the barbell: 37 |

38 | A falsely logged front squat. 39 |

40 | The user can correct this false rep by changing its "Movement" column value to "FALSE": 41 |

42 | Corrected front squat set. 43 | 44 | 45 | -------------------------------------------------------------------------------- /utils/webcam.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | 3 | 4 | def list_available_cameras(): 5 | """ 6 | Searches for available webcams and returns a list of the ones that are found. 7 | 8 | Returns 9 | ------- 10 | list 11 | Indices of available webcams that can be used for VideoCapture(). 12 | """ 13 | camera_index = 0 14 | camera_list = [] 15 | while True: 16 | cap = cv2.VideoCapture(camera_index) 17 | if cap.isOpened() is False: 18 | break 19 | else: 20 | camera_list.append(camera_index) 21 | cap.release() 22 | cv2.destroyAllWindows() 23 | camera_index += 1 24 | return camera_list 25 | 26 | 27 | def initiate_camera(camera_index): 28 | """ 29 | Starts a VideoCapture object for streaming or recording. 30 | 31 | Parameters 32 | ---------- 33 | camera_index : int 34 | Index of the camera to initiate. 35 | 36 | Returns 37 | ------- 38 | VideoCapture 39 | cv2.VideoCapture object 40 | """ 41 | camera = cv2.VideoCapture(camera_index, cv2.CAP_DSHOW) 42 | if camera.isOpened() is False: 43 | print('Camera unable to be opened.') 44 | # TODO Change this to a message box 45 | width = 1280 46 | height = 960 47 | camera.set(cv2.CAP_PROP_FRAME_WIDTH, width) 48 | camera.set(cv2.CAP_PROP_FRAME_HEIGHT, height) 49 | return camera 50 | -------------------------------------------------------------------------------- /docs/requirements.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | barbellcv Requirements 6 | 19 | 20 | 21 | 22 |

|||---barbell-cv---|||

23 | 24 |

Table of Contents

25 | 26 | Requirements
27 | Setup
28 | Logging Sets
29 | Editing Sets
30 | Accuracy Considerations
31 | Back to Main
32 | 33 |

Requirements

34 | 35 |

36 | Coming soon after more testing. It's summer in Texas and like 235203845109857 degrees in my garage so I don't 37 | currently train with the garage door closed (seriously it's an oven) and can't do some decent testing. 38 |

39 | 40 |

41 | Until I make a way to account for lens distortion, you'll need to use a webcam with no fisheye effect. Also, 42 | the marker on your barbell must be a unique color. I use a 3" wooden disk spray painted fluorescent orange mounted 43 | to the end of a short piece of PVC that I put on the end of the barbell. This works as long as there is nothing 44 | larger than this disk that is the same color in the field of view! 45 |

46 | 47 | 48 | -------------------------------------------------------------------------------- /main.pyw: -------------------------------------------------------------------------------- 1 | # Standard library imports 2 | import os 3 | import sys 4 | import time 5 | from shutil import copyfile 6 | # External library imports 7 | import qdarkstyle 8 | import pyqtgraph as pg 9 | from PyQt5 import QtCore 10 | from PyQt5.QtWidgets import QApplication 11 | # Custom imports 12 | from apps import barbellcvlog 13 | from utils import database 14 | 15 | # Need to scale to screen resolution - this handles 4k scaling 16 | if hasattr(QtCore.Qt, 'AA_EnableHighDpiScaling'): 17 | QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling, True) 18 | if hasattr(QtCore.Qt, 'AA_UseHighDpiPixmaps'): 19 | QApplication.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps, True) 20 | 21 | # Match pyqtgraph background to QDarkStyle background 22 | pg.setConfigOption('background', '#19232D') 23 | pg.setConfigOptions(antialias=True) 24 | 25 | # ALL data is saved to the data directory for now - this needs to exist 26 | if os.path.isdir('./data/') is False: 27 | os.mkdir('./data/') 28 | 29 | # Database is in top level of data directory 30 | if os.path.isfile('./data/history.db') is False: 31 | con = database.connect_db(os.path.abspath('./data/history.db')) 32 | database.create_db_tables(con) 33 | con.close() 34 | # Create a backup so we can restore if needed 35 | else: 36 | copyfile(os.path.abspath('./data/history.db'), os.path.abspath('./data/history_backup.db')) 37 | 38 | # Logs and videos are saved to subdirectories named with date stamps 39 | if os.path.isdir(f"./data/{time.strftime('%y%m%d')}") is False: 40 | os.mkdir(f"./data/{time.strftime('%y%m%d')}") 41 | 42 | 43 | if __name__ == "__main__": 44 | log_app = QApplication(sys.argv) 45 | window = barbellcvlog.BarbellCVLogApp() 46 | log_app.setStyleSheet(qdarkstyle.load_stylesheet_pyqt5()) 47 | window.show() 48 | sys.exit(log_app.exec_()) 49 | -------------------------------------------------------------------------------- /docs/documentation.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | barbellcv Documentation 6 | 19 | 20 | 21 | 22 |

|||---barbell-cv---|||

23 | 24 |

Table of Contents

25 | 26 | Requirements
27 | Setup
28 | Logging Sets
29 | Editing Sets
30 | Accuracy Considerations
31 | 32 |

Quick Start

33 | 34 |
    35 |
  1. Preview your webcam using the "Preview" button, and rotate it if needed using the adjacent dropdown. 36 | Press Enter to escape the preview.
  2. 37 |
  3. Select the color of your barbell marker interactively.
  4. 38 | 44 |
  5. Select the exercise you want to do.
  6. 45 | 48 |
  7. Input the weight for the set in lbs or kgs.
  8. 49 |
  9. Press "Log Set" and wait for the webcam preview to show before lifting.
  10. 50 |
  11. After lifting, press the Enter key to complete the set.
  12. 51 |
  13. The results for the set are shown.
  14. 52 |
53 | 54 | Pass/fail criteria for reps. 55 | 56 | 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data/ 2 | resources/settings.json 3 | test_sample.py 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | pip-wheel-metadata/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # PyCharm 90 | .idea 91 | .idea/ 92 | .idea/misc.xml 93 | .idea/workspace.xml 94 | 95 | # pyenv 96 | # For a library or package, you might want to ignore these files since the code is 97 | # intended to run in multiple environments; otherwise, check them in: 98 | # .python-version 99 | 100 | # pipenv 101 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 102 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 103 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 104 | # install all needed dependencies. 105 | #Pipfile.lock 106 | 107 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 108 | __pypackages__/ 109 | 110 | # Celery stuff 111 | celerybeat-schedule 112 | celerybeat.pid 113 | 114 | # SageMath parsed files 115 | *.sage.py 116 | 117 | # Environments 118 | .env 119 | .venv 120 | env/ 121 | venv/ 122 | ENV/ 123 | env.bak/ 124 | venv.bak/ 125 | 126 | # Spyder project settings 127 | .spyderproject 128 | .spyproject 129 | 130 | # Rope project settings 131 | .ropeproject 132 | 133 | # mkdocs documentation 134 | /site 135 | 136 | # mypy 137 | .mypy_cache/ 138 | .dmypy.json 139 | dmypy.json 140 | 141 | # Pyre type checker 142 | .pyre/ 143 | 144 | # pytype static type analyzer 145 | .pytype/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # |||---barbell-cv---||| 2 | 3 | Get started with velocity-based training with nothing but a laptop, a webcam, 4 | and a high contrast marker on your barbell. 5 | 6 | ![Power snatch logged in barbellcv.](docs/screenshots/front_squat_failed.png) 7 | 8 | ## Quick Start 9 | 10 | 1. Download this repository as a .zip file, then unzip it where you like. 11 | 2. Go to that directory using CMD (Windows) or the terminal (Mac/Linux. 12 | 3. Make sure the correct dependencies are installed using pip: 13 | ``` 14 | python -m pip install -r requirements.txt 15 | ``` 16 | 4. Run the program: 17 | ``` 18 | python main.pyw 19 | ``` 20 | 5. Preview your webcam using the "Preview" button, and rotate it if needed using the adjacent dropdown. 21 | Press Enter to escape the preview. 22 | 6. Select the color of your barbell marker interactively. 23 | - Press the "Select Color" button. 24 | - Drag your mouse over the marker in the popup (make sure to move the marker around and select it 25 | under varying light conditions and angles). 26 | - Press Enter when the marker is tracked satisfactorily. 27 | 7. Select the exercise you want to do. 28 | - Add exercises that you find missing to the /resources/lifts.json file. 29 | 8. Input the weight for the set in lbs or kgs. 30 | 9. Press "Log Set" and wait for the webcam preview to show before lifting. 31 | 10. After lifting, press the Enter key to complete the set. 32 | 11. The results for the set are shown. 33 | 34 | ![Output from example set.](docs/screenshots/failure_criteria.png) 35 | 36 | 12. Optionally reclassify phases of lifts for each rep. 37 | - For example, three reps will likely be found for a single clean and jerk. Individually label the 38 | concentric phases of the clean, then the front squat, then the jerk separately using the dropdown 39 | in row 1 of the table. 40 | - Also mark false positive reps as FALSE, failed reps as FAILED, or incorrectly detected reps for 41 | which the entire ROM was not found as PARTIAL. 42 | - Below is an example of a set of front squat triples where the unracking of the bar was detected 43 | as rep 1. The lifter was able to reclassify this false positive rep as FALSE, erasing that incorrect 44 | "rep" from the set. 45 | 46 | ![Front squat set where the unracking was detected as a false rep.](docs/screenshots/front_squat_false_rep.png) 47 | 48 | ![Front squat set where the unracking was corrected.](docs/screenshots/front_squat_corrected.png) 49 | 50 | ## Instructions 51 | *More in-depth instructions coming soon after testing or available on request.* 52 | 53 | ## Limitations 54 | For the video analysis to work correctly on Windows 10 you may need to install the basic 55 | [K-Lite Codec Pack](https://codecguide.com/download_kl.htm). 56 | 57 | *As I test this setup more I will learn more about its accuracy and what the limitations are, then update 58 | this section. Suffice it to say that all results should be taken with a grain of salt until verified.* 59 | 60 | *If you install this program and use it for your training, I'd love to hear your feedback. For any 61 | bugs or suggestions please open an issue here.* 62 | -------------------------------------------------------------------------------- /utils/database.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | import pandas as pd 3 | 4 | 5 | def connect_db(db_path): 6 | """ 7 | Utility function to get the database connection or create it if it doesn't exist. 8 | 9 | Parameters 10 | ---------- 11 | db_path : string 12 | Path to the database's location on disk. 13 | 14 | Returns 15 | ------- 16 | sqlite3 connection object 17 | Link to the database. 18 | """ 19 | db_conn = sqlite3.connect(db_path) 20 | return db_conn 21 | 22 | 23 | def create_db_tables(db_conn): 24 | """ 25 | Ensures the necessary tables are present in the database when initialized. 26 | 27 | Parameters 28 | ---------- 29 | db_conn : sqlite3 connection object 30 | Link to the database. 31 | """ 32 | 33 | c = db_conn.cursor() 34 | 35 | c.execute(""" CREATE TABLE IF NOT EXISTS set_history( 36 | set_id text PRIMARY KEY, 37 | lifter text, 38 | lift text, 39 | weight real, 40 | nominal_diameter real, 41 | pixel_calibration real, 42 | number_of_reps real 43 | ); """) 44 | c.execute(""" CREATE TABLE IF NOT EXISTS rep_history( 45 | rep_id text PRIMARY KEY, 46 | set_id text REFERENCES set_history(set_id), 47 | lift text, 48 | average_velocity real, 49 | peak_velocity real, 50 | peak_power real, 51 | peak_height real, 52 | x_rom real, 53 | y_rom real, 54 | t_concentric real, 55 | movement text 56 | ); """) 57 | 58 | 59 | def update_set_history(db_path, set_stats): 60 | """ 61 | Commits the set statistics to the database. 62 | 63 | Parameters 64 | ---------- 65 | set_stats : Dictionary 66 | Metadata for the set. 67 | """ 68 | con = connect_db(db_path) 69 | c = con.cursor() 70 | # [:-1] is included here to avoid inserting the rep_stats dict, which is added last just before this is called 71 | sql = 'INSERT OR REPLACE INTO set_history(' \ 72 | 'set_id,lifter,lift,weight,nominal_diameter,pixel_calibration,number_of_reps) ' \ 73 | 'VALUES (?,?,?,?,?,?,?)' 74 | values = list(set_stats.values())[:-1] 75 | c.execute(sql, values) 76 | con.commit() 77 | con.close() 78 | 79 | 80 | def update_rep_history(db_path, rep_stats): 81 | """ 82 | Commits the rep statistics to the database. 83 | 84 | Parameters 85 | ---------- 86 | rep_stats : Dictionary 87 | Metadata for each rep. 88 | """ 89 | con = connect_db(db_path) 90 | c = con.cursor() 91 | for rep in rep_stats.keys(): 92 | sql = 'INSERT OR REPLACE INTO rep_history(' \ 93 | 'rep_id,set_id,lift,average_velocity,peak_velocity,peak_power,peak_height,x_rom,y_rom,t_concentric,movement) ' \ 94 | 'VALUES (?,?,?,?,?,?,?,?,?,?,?)' 95 | values = list(rep_stats[rep].values()) 96 | c.execute(sql, values) 97 | con.commit() 98 | con.close() 99 | 100 | 101 | def export_to_csv(db_path, base_path): 102 | """ 103 | Writes set and rep history to a single Excel file with multiple sheets. 104 | 105 | Parameters 106 | ---------- 107 | db_path : string 108 | Path to the database's location on disk. 109 | base_path : string 110 | Base name for the CSV files on disk. 111 | """ 112 | con = connect_db(db_path) 113 | sets = pd.read_sql('SELECT * FROM set_history', con, index_col='set_id') 114 | reps = pd.read_sql('SELECT * FROM rep_history', con, index_col='rep_id') 115 | sets.to_csv(base_path.replace('.csv', '_sets.csv')) 116 | reps.to_csv(base_path.replace('.csv', '_reps.csv')) 117 | -------------------------------------------------------------------------------- /resources/lifts.json: -------------------------------------------------------------------------------- 1 | { 2 | "backsquat": { 3 | "name": "Back Squat", 4 | "pf_metric": "t_concentric", 5 | "pf_criterion": "<=1.0", 6 | "movements": "none" 7 | }, 8 | "benchpress": { 9 | "name": "Bench Press", 10 | "pf_metric": "t_concentric", 11 | "pf_criterion": "<=1.0", 12 | "movements": "none" 13 | }, 14 | "clean": { 15 | "name": "Clean", 16 | "pf_metric": "peak_velocity", 17 | "pf_criterion": ">=1.4", 18 | "movements": ["Front Squat"] 19 | }, 20 | "cleanandjerk": { 21 | "name": "Clean and Jerk", 22 | "pf_metric": "peak_velocity", 23 | "pf_criterion": ">=1.4", 24 | "movements": ["Clean", "Front Squat", "Jerk"] 25 | }, 26 | "cleanpull": { 27 | "name": "Clean Pull", 28 | "pf_metric": "t_concentric", 29 | "pf_criterion": "<=0.7", 30 | "movements": "none" 31 | }, 32 | "complex": { 33 | "name": "Complex", 34 | "pf_metric": "t_concentric", 35 | "pf_criterion": "<=1.0", 36 | "movements": "all" 37 | }, 38 | "deadlift": { 39 | "name": "Deadlift", 40 | "pf_metric": "t_concentric", 41 | "pf_criterion": "<=1.0", 42 | "movements": "none" 43 | }, 44 | "frontsquat": { 45 | "name": "Front Squat", 46 | "pf_metric": "t_concentric", 47 | "pf_criterion": "<=1.0", 48 | "movements": "none" 49 | }, 50 | "hangclean": { 51 | "name": "Hang Clean", 52 | "pf_metric": "peak_velocity", 53 | "pf_criterion": ">=1.4", 54 | "movements": ["Hang Power Clean", "Front Squat"] 55 | }, 56 | "hangpowerclean": { 57 | "name": "Hang Power Clean", 58 | "pf_metric": "peak_velocity", 59 | "pf_criterion": ">=1.4", 60 | "movements": ["Hang Clean", "Front Squat"] 61 | }, 62 | "hangpowersnatch": { 63 | "name": "Hang Power Snatch", 64 | "pf_metric": "peak_velocity", 65 | "pf_criterion": ">=1.8", 66 | "movements": ["Hang Snatch", "Overhead Squat"] 67 | }, 68 | "hangsnatch": { 69 | "name": "Hang Snatch", 70 | "pf_metric": "peak_velocity", 71 | "pf_criterion": ">=1.8", 72 | "movements": ["Hang Power Snatch", "Overhead Squat"] 73 | }, 74 | "inclinebenchpress": { 75 | "name": "Incline Bench Press", 76 | "pf_metric": "t_concentric", 77 | "pf_criterion": "<=1.0", 78 | "movements": "none" 79 | }, 80 | "jerk": { 81 | "name": "Jerk", 82 | "pf_metric": "peak_velocity", 83 | "pf_criterion": ">=2.0", 84 | "movements": ["Stand"] 85 | }, 86 | "musclesnatch": { 87 | "name": "Muscle Snatch", 88 | "pf_metric": "t_concentric", 89 | "pf_criterion": "<=1.0", 90 | "movements": ["Press", "Overhead Squat"] 91 | }, 92 | "overheadpress": { 93 | "name": "Overhead Press", 94 | "pf_metric": "t_concentric", 95 | "pf_criterion": "<=1.0", 96 | "movements": "none" 97 | }, 98 | "overheadsquat": { 99 | "name": "Overhead Squat", 100 | "pf_metric": "t_concentric", 101 | "pf_criterion": "<=1.0", 102 | "movements": ["Snatch Balance", "Jerk"] 103 | }, 104 | "powerclean": { 105 | "name": "Power Clean", 106 | "pf_metric": "peak_velocity", 107 | "pf_criterion": ">=1.4", 108 | "movements": ["Clean", "Front Squat"] 109 | }, 110 | "powerjerk": { 111 | "name": "Power Jerk", 112 | "pf_metric": "peak_velocity", 113 | "pf_criterion": ">=2.0", 114 | "movements": ["Overhead Squat"] 115 | }, 116 | "powersnatch": { 117 | "name": "Power Snatch", 118 | "pf_metric": "peak_velocity", 119 | "pf_criterion": ">=1.8", 120 | "movements": ["Snatch", "Overhead Squat"] 121 | }, 122 | "pushpress": { 123 | "name": "Push Press", 124 | "pf_metric": "t_concentric", 125 | "pf_criterion": "<=1.0", 126 | "movements": "none" 127 | }, 128 | "snatch": { 129 | "name": "Snatch", 130 | "pf_metric": "peak_velocity", 131 | "pf_criterion": ">=1.8", 132 | "movements": ["Overhead Squat"] 133 | }, 134 | "snatchbalance": { 135 | "name": "Snatch Balance", 136 | "pf_metric": "peak_velocity", 137 | "pf_criterion": ">=1.8", 138 | "movements": ["Overhead Squat"] 139 | }, 140 | "snatchpull": { 141 | "name": "Snatch Pull", 142 | "pf_metric": "t_concentric", 143 | "pf_criterion": "<=0.7", 144 | "movements": "none" 145 | }, 146 | "tallsnatch": { 147 | "name": "Tall Snatch", 148 | "pf_metric": "peak_velocity", 149 | "pf_criterion": ">=1.8", 150 | "movements": ["Hang Snatch", "Overhead Squat"] 151 | }, 152 | "readiness": { 153 | "name": "Readiness", 154 | "pf_metric": "peak_velocity", 155 | "pf_criterion": ">=1.8", 156 | "movements": "all" 157 | } 158 | } -------------------------------------------------------------------------------- /utils/analyze.py: -------------------------------------------------------------------------------- 1 | import time 2 | import cv2 3 | import numpy as np 4 | import pandas as pd 5 | from scipy.ndimage import label 6 | from scipy.ndimage.filters import maximum_filter1d, minimum_filter1d 7 | 8 | 9 | def apply_mask(frame, lower, upper, kernel): 10 | """ 11 | Masks video frames by the selected color range. Frame must be in native OpenCV color space. 12 | 13 | Parameters 14 | ---------- 15 | frame : (N, M) array 16 | Array representing a single video frame in BGR color space. 17 | lower : (N) array of length 3 18 | Array representing the lower HSV values for masking. 19 | upper : (N) array of length 3 20 | Array representing the upper HSV values for masking. 21 | kernel : cv2 StructuringElement 22 | Smoothing kernel that is used for closing the mask. 23 | 24 | Returns 25 | ------- 26 | (N, M) array 27 | Video frame that is masked by the currently selected colors. 28 | 29 | """ 30 | hsv_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) 31 | mask = cv2.inRange(hsv_frame, lower, upper) 32 | return cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) 33 | 34 | 35 | def find_reps(y, threshold, open_size, close_size): 36 | """ 37 | From the Y profile of a barbell's path, determine the concentric phase of each rep. 38 | 39 | The algorithm is as follows: 40 | 1. Compute the gradient (dy/dt) of the Y motion 41 | 2. Binarize the gradient signal by a minimum threshold value to eliminate noise. 42 | 3. Perform 1D opening by open_size using a minimum then maximum filter in series. 43 | 4. Perform 1D closing by close_size using a maximum then minimum filter in series. 44 | 45 | The result is a step function that is true for every time point that the concentric (+Y) phase of the rep 46 | is being performed. 47 | 48 | Parameters 49 | ---------- 50 | y : (N) array 51 | Y component of the motion of the barbell path. 52 | threshold : float 53 | Miniumum acceptable value of the gradient (dY/dt) to indicate a rep. 54 | Increasing this can help eliminate noise, but may cause a small delay after a rep begins to when it is 55 | counted, therefore underestimating the time to complete a rep. 56 | open_size : int 57 | Minimum threshold of length of time that it takes to complete a rep (in frames). 58 | Increase this if there are false positive spikes in the rep step signal that are small in width. 59 | close_size : int 60 | Minimum length of time that could be between reps. 61 | Increase this if there are false breaks between reps that should be continuous. 62 | 63 | Returns 64 | ------- 65 | (N) array 66 | Step signal representing when reps are performed. (1 indicates concentric phase of rep, 0 indicates no rep). 67 | """ 68 | ygrad = np.gradient(y) 69 | rep_signal = np.where(ygrad > threshold, 1, 0) 70 | 71 | # Opening to remove spikes 72 | rep_signal = maximum_filter1d(minimum_filter1d(rep_signal, open_size), open_size) 73 | 74 | # Closing to connect movements (as in the step up from the jerk) 75 | rep_signal = minimum_filter1d(maximum_filter1d(rep_signal, close_size), close_size) 76 | 77 | return rep_signal 78 | 79 | 80 | def reject_outliers(arr, window_size, threshold): 81 | """ 82 | Given a 1D signal, replace outliers with the average of the surrounding points. 83 | 84 | Does the following for every point: 85 | 1. Computes the median of the sliding window 86 | 2. Computes the percentage difference between the current point and the 87 | sliding window median 88 | 3. If that deviation exceeds the given threshold, replaces that point with 89 | the average of the surrounding two points. 90 | 4. Otherwise, returns the same point. 91 | 92 | Parameters 93 | ---------- 94 | arr : 1D array 95 | Signal with outliers 96 | window_size : int 97 | Size of sliding window 98 | threshold : float 99 | Minimum acceptable percentage deviation (as decimal) 100 | 101 | Returns 102 | ------- 103 | result : 1D array 104 | Original signal with outliers removed. 105 | """ 106 | if len(arr.shape) > 1: 107 | print('Only 1D arrays supported.') 108 | return 109 | 110 | result = np.empty_like(arr) 111 | 112 | for i in range(0, arr.shape[0]): 113 | min_i = int(max(0, np.floor(i - window_size / 2))) 114 | max_i = int(min(arr.shape[0], np.floor(i + window_size / 2 + 1))) 115 | med = np.median(arr[min_i:max_i]) 116 | dev = np.abs(arr[i] - med) / med 117 | if dev > threshold: 118 | result[i] = np.average([arr[i - 1], arr[i + 1]]) 119 | else: 120 | result[i] = arr[i] 121 | 122 | return result 123 | 124 | 125 | def analyze_set(t, x, y, r, diameter): 126 | """ 127 | Calculates statistics of a set from barbell tracking arrays. 128 | 129 | Parameters 130 | ---------- 131 | t : (N) array 132 | Time in seconds from the beginning of the recording that each frame is taken. 133 | x : (N) array 134 | X location of the barbell in pixels. 135 | y : (N) array 136 | Y location of the barbell in pixels. 137 | r : (N) array 138 | Radius of the barbell marker in pixels measured at each time step. 139 | diameter : float 140 | Nominal diameter of the marker in mm, taken from the GIU. 141 | 142 | Returns 143 | ------- 144 | List 145 | Index 0: DataFrame 146 | A new DataFrame with updated information for more advanced analytics. 147 | Index 1: Float 148 | Pixel calibration factor used to convert pixels to meters. 149 | """ 150 | 151 | # Need to zero out bullshit numbers like 3.434236345E-120 or some shit 152 | t[0] = 0 153 | t[np.abs(t) < 0.0000001] = 0 154 | x[np.abs(x) < 0.0000001] = 0 155 | y[np.abs(y) < 0.0000001] = 0 156 | r[np.abs(r) < 0.0000001] = 0 157 | 158 | # Need some info from the UI 159 | nominal_radius = diameter / 2 / 1000 # meters 160 | # Calculate meter/pixel calibration from beginning of log when barbell isn't moving 161 | # Take it from frame 5 - 20 to allow time for video to "start up" (if that's even a thing?) 162 | calibration = nominal_radius / np.median(r[5:20]) 163 | 164 | # Smooth motion to remove outliers 165 | xsmooth = reject_outliers(x, 3, 0.3) 166 | ysmooth = reject_outliers(y, 3, 0.3) 167 | 168 | # Calibrate x and y movement 169 | xcal = (xsmooth - np.min(xsmooth)) * calibration # meters 170 | ycal = (ysmooth - np.min(ysmooth)) * calibration # meters 171 | 172 | # Calculate displacement and velocity 173 | displacement = np.sqrt(np.diff(xcal, prepend=xcal[0]) ** 2 + np.diff(ycal, prepend=ycal[0]) ** 2) # m 174 | velocity = np.zeros(shape=t.shape, dtype=np.float32) # m/s 175 | velocity[1:] = displacement[1:] / np.diff(t) 176 | 177 | # Find the reps and label them 178 | reps_binary = find_reps(ycal, threshold=0.01, open_size=5, close_size=9) 179 | 180 | set_analyzed = pd.DataFrame() 181 | set_analyzed['Time'] = t 182 | set_analyzed['X_pix'] = x 183 | set_analyzed['Y_pix'] = y 184 | set_analyzed['R_pix'] = r 185 | set_analyzed['X_m'] = xcal 186 | set_analyzed['Y_m'] = ycal 187 | set_analyzed['Displacement'] = displacement 188 | set_analyzed['Velocity'] = velocity 189 | set_analyzed['Reps'] = reps_binary 190 | 191 | return set_analyzed, calibration 192 | 193 | 194 | def analyze_reps(set_data, set_stats, lifts): 195 | """ 196 | Given an analyzed set log, calculate metrics for each rep that is found for updating the table and plots. 197 | 198 | Any NumPy results are cast to float32 so they map properly to the real data type in SQLite. See here why this 199 | tedious method is used: https://github.com/numpy/numpy/issues/6860 200 | 201 | Parameters 202 | ---------- 203 | set_data : DataFrame 204 | Data collected and analyzed from logging the set. Expected columns are Time, Velocity, X_m, Y_m, and Reps. 205 | set_stats : Dictionary 206 | Metadata for the set. The only expected keys is weight, but number_of_reps is added and returned. 207 | lifts : Dictionary 208 | Library of lifts from lifts.json. 209 | 210 | Returns 211 | ------- 212 | list of dictionaries 213 | Index 0: set_stats dictionary updated with number of reps 214 | Index 1: rep_stats dictionary with metrics measured for each rep 215 | """ 216 | reps_labeled, n_reps = label(set_data['Reps'].values) 217 | set_stats['number_of_reps'] = n_reps 218 | velocity = set_data['Velocity'].values 219 | xcal = set_data['X_m'].values 220 | ycal = set_data['Y_m'].values 221 | rep_stats = {} 222 | for rep in range(1, n_reps + 1): 223 | idx = tuple([reps_labeled == rep]) 224 | rep_stats[f"rep{rep}"] = {} 225 | rep_stats[f"rep{rep}"]['rep_id'] = f"{set_stats['set_id']}_{rep}" 226 | rep_stats[f"rep{rep}"]['set_id'] = set_stats['set_id'] 227 | rep_stats[f"rep{rep}"]['lift'] = set_stats['lift'] 228 | rep_stats[f"rep{rep}"]['average_velocity'] = float(np.average(velocity[idx])) 229 | rep_stats[f"rep{rep}"]['peak_velocity'] = float(np.max(velocity[idx])) 230 | rep_stats[f"rep{rep}"]['peak_power'] = float(set_stats['weight'] * 9.80665 * rep_stats[f"rep{rep}"]['peak_velocity']) 231 | rep_stats[f"rep{rep}"]['peak_height'] = float(ycal[idx][np.argmax(velocity[idx])]) 232 | rep_stats[f"rep{rep}"]['x_rom'] = float(np.max(xcal[idx]) - np.min(xcal[idx])) 233 | rep_stats[f"rep{rep}"]['y_rom'] = float(np.max(ycal[idx]) - np.min(ycal[idx])) 234 | rep_stats[f"rep{rep}"]['t_concentric'] = float(set_data['Time'].values[idx][-1] - set_data['Time'].values[idx][0]) 235 | rep_stats[f"rep{rep}"]['movement'] = set_stats['lift'] 236 | 237 | return set_stats, rep_stats 238 | 239 | 240 | def post_process_video(video_file, n_frames, set_data): 241 | """ 242 | Opens a video file, traces the bar path, then saves it back to disk with the correct framerate. 243 | 244 | Parameters 245 | ---------- 246 | video_file : string 247 | Path to the video file. 248 | n_frames : int 249 | Number of frames of the video. 250 | set_data : DataFrame 251 | Full DataFrame obtained from analyzing the set containing (at minimum) the t, x, y coordinates of the bar. 252 | """ 253 | 254 | # Get smoothed motion to remove outliers and make path nicer 255 | xsmooth = reject_outliers(set_data['X_pix'].values, 3, 0.3) 256 | ysmooth = reject_outliers(set_data['Y_pix'].values, 3, 0.3) 257 | 258 | # Open video stream 259 | cap = cv2.VideoCapture(video_file) 260 | if cap.isOpened() is False: 261 | print('Camera unable to be opened.') 262 | # TODO Change this to a message box 263 | time.sleep(1) 264 | width = int(cap.get(3)) 265 | height = int(cap.get(4)) 266 | # Estimate correct fps and save to that 267 | fps = int(n_frames / set_data['Time'].values[-1]) 268 | video_out = cv2.VideoWriter(video_file.replace('.mp4', '_traced.mp4'), 269 | cv2.VideoWriter_fourcc(*'mp4v'), fps, (width, height)) 270 | current_frame = 0 271 | while True: 272 | ret, frame = cap.read() 273 | if ret is False: 274 | break 275 | if current_frame > 6: 276 | try: 277 | for cf in range(5, current_frame-1): 278 | xy1 = (int(xsmooth[cf-1]), int(ysmooth[cf-1])) 279 | xy2 = (int(xsmooth[cf]), int(ysmooth[cf])) 280 | cv2.line(frame, xy1, xy2, (255, 255, 255), 1) 281 | # TODO: Plot max velocities in a different color or as a different size 282 | # TODO Fix indexing to avoid dotted line look 283 | except IndexError: 284 | pass 285 | video_out.write(frame) 286 | current_frame += 1 287 | cap.release() 288 | video_out.release() 289 | cv2.destroyAllWindows() 290 | -------------------------------------------------------------------------------- /apps/barbellcvlog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Trevor Lancon 4 | MainWindow 5 | 6 | 7 | 8 | 0 9 | 0 10 | 1200 11 | 740 12 | 13 | 14 | 15 | 16 | 1000 17 | 600 18 | 19 | 20 | 21 | barbellcv 22 | 23 | 24 | false 25 | 26 | 27 | QTabWidget::Rounded 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 8 41 | 42 | 43 | 44 | Marker Diameter 45 | 46 | 47 | 48 | 49 | 50 | 51 | Weight 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 8 60 | 61 | 62 | 63 | Exercise 64 | 65 | 66 | 67 | 68 | 69 | 70 | Save Settings 71 | 72 | 73 | 74 | 75 | 76 | 77 | 180 78 | 79 | 80 | 81 | 82 | 83 | 84 | Hue 85 | 86 | 87 | 88 | 89 | 90 | 91 | Value 92 | 93 | 94 | 95 | 96 | 97 | 98 | Saturation 99 | 100 | 101 | 102 | 103 | 104 | 105 | 255 106 | 107 | 108 | 109 | 110 | 111 | 112 | 255 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | Camera 123 | 124 | 125 | 126 | 127 | 128 | 129 | Qt::Horizontal 130 | 131 | 132 | 133 | 134 | 135 | 136 | Camera Rotation 137 | 138 | 139 | 140 | 141 | 142 | 143 | Qt::Horizontal 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 0 155 | 0 156 | 157 | 158 | 159 | Preview 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 0 168 | 0 169 | 170 | 171 | 172 | Select Color 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 1 183 | 184 | 185 | 1102.299999999999955 186 | 187 | 188 | 45.000000000000000 189 | 190 | 191 | 192 | 193 | 194 | 195 | 1000 196 | 197 | 198 | 199 | 200 | 201 | 202 | kgs 203 | 204 | 205 | 206 | 207 | 208 | 209 | 1 210 | 211 | 212 | 501.000000000000000 213 | 214 | 215 | 20.000000000000000 216 | 217 | 218 | 219 | 220 | 221 | 222 | 255 223 | 224 | 225 | 255 226 | 227 | 228 | 229 | 230 | 231 | 232 | lbs 233 | 234 | 235 | 236 | 237 | 238 | 239 | 180 240 | 241 | 242 | 180 243 | 244 | 245 | 246 | 247 | 248 | 249 | 255 250 | 251 | 252 | 255 253 | 254 | 255 | 256 | 257 | 258 | 259 | mm 260 | 261 | 262 | 263 | 264 | 265 | 266 | Lifter 267 | 268 | 269 | 270 | 271 | 272 | 273 | Lifter 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 0 282 | 0 283 | 284 | 285 | 286 | 287 | 8 288 | 289 | 290 | 291 | false 292 | 293 | 294 | Log Set 295 | 296 | 297 | 298 | 299 | 300 | 301 | Save video 302 | 303 | 304 | 305 | 306 | 307 | 308 | false 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 0 327 | 0 328 | 1200 329 | 21 330 | 331 | 332 | 333 | 334 | File 335 | 336 | 337 | 338 | 339 | 340 | 341 | Help 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | Options 351 | 352 | 353 | 354 | 355 | Camera 356 | 357 | 358 | 359 | 360 | Camera 361 | 362 | 363 | 364 | 365 | Save settings 366 | 367 | 368 | 369 | 370 | Clear saved videos 371 | 372 | 373 | 374 | 375 | Export database to CSV... 376 | 377 | 378 | 379 | 380 | Refresh camera list 381 | 382 | 383 | 384 | 385 | Documentation 386 | 387 | 388 | 389 | 390 | 391 | PlotWidget 392 | QWidget 393 |
pyqtgraph
394 | 1 395 |
396 |
397 | 398 | comboCamera 399 | comboRotation 400 | buttonPreview 401 | spinMinHue 402 | spinMaxHue 403 | spinMinSaturation 404 | spinMaxSaturation 405 | spinMinValue 406 | spinMaxValue 407 | buttonSelectColor 408 | spinDiameter 409 | buttonSaveSettings 410 | spinLbs 411 | spinKgs 412 | comboExercise 413 | lineEditLifter 414 | checkSaveVideo 415 | buttonLogSet 416 | lineEditLogPath 417 | tableSetStats 418 | 419 | 420 | 421 |
422 | -------------------------------------------------------------------------------- /apps/barbellcvlog.py: -------------------------------------------------------------------------------- 1 | # Standard library imports 2 | import os 3 | import time 4 | import json 5 | from collections import deque 6 | # External library imports 7 | import cv2 8 | import numpy as np 9 | import pyqtgraph as pg 10 | from scipy.ndimage import label 11 | from PyQt5 import QtCore, QtGui, QtWidgets, uic 12 | # Custom imports 13 | from . import splashscreen, documentation 14 | from utils import analyze, webcam, database 15 | 16 | DATA_DIR = os.path.dirname(f"./data/{time.strftime('%y%m%d')}/") 17 | DB_PATH = os.path.abspath('./data/history.db') 18 | DB_BACKUP_PATH = os.path.abspath('./data/history_backup.db') 19 | 20 | COLOR_SCHEME = {'darkblue': '#19232D', 21 | 'orange': '#E4572E', 22 | 'lightblue': '#17BEEB', 23 | 'yellow': '#FFC914', 24 | 'green': '#76B041', 25 | 'white': '#FFFFFF'} 26 | 27 | qtCreatorFile = os.path.abspath('./apps/barbellcvlog.ui') 28 | iconFile = os.path.abspath('./apps/barbellcvicon.ico') 29 | Ui_MainWindow, QtBaseClass = uic.loadUiType(qtCreatorFile) 30 | 31 | 32 | class BarbellCVLogApp(QtWidgets.QMainWindow, Ui_MainWindow): 33 | def __init__(self): 34 | QtWidgets.QMainWindow.__init__(self) 35 | Ui_MainWindow.__init__(self) 36 | self.setupUi(self) 37 | self.setWindowIcon(QtGui.QIcon(iconFile)) 38 | 39 | # Call splash window to display logo while app loads 40 | self.splash_screen = splashscreen.SplashWindow() 41 | 42 | # Connect signals 43 | self.buttonPreview.clicked.connect(self.preview_camera) 44 | self.buttonSelectColor.clicked.connect(self.select_colors) 45 | self.buttonSaveSettings.clicked.connect(self.save_settings) 46 | self.buttonLogSet.clicked.connect(self.log_set) 47 | self.spinLbs.editingFinished.connect(self.lbs_changed) 48 | self.spinKgs.editingFinished.connect(self.kgs_changed) 49 | self.actionExportToCSV.triggered.connect(self.export_database) 50 | self.actionRefreshCameraList.triggered.connect(self.refresh_cameras) 51 | self.actionDocumentation.triggered.connect(self.show_documentation) 52 | 53 | # Set up camera options 54 | # Find available cameras 55 | self.camera_list = [] 56 | self.refresh_cameras() 57 | # Set up rotation 58 | for r in ['0', '90', '180', '270']: 59 | self.comboRotation.addItem(r + u"\u00b0") 60 | 61 | # Load parameters from previous session if present 62 | if os.path.isfile('./resources/settings.json') is True: 63 | self.load_settings() 64 | 65 | # Load lifts to dropdown 66 | # TODO Check if pass/fail criteria keys are empty and have mechanism to revert back to peak_velocity if not 67 | dlf = open('./resources/lifts.json', 'r') 68 | self.lifts = json.load(dlf) 69 | dlf.close() 70 | for lift in self.lifts: 71 | self.comboExercise.addItem(self.lifts[lift]['name']) 72 | 73 | # Set up table for display 74 | table_headers = ['Movement', 'Avg Vel (m/s)', 'Pk Vel (m/s)', 'Pk Power (W)', 'Y at Pk (m)', 75 | 'X ROM (m)', 'Y ROM (m)', 'Conc. Time (s)'] 76 | self.tableSetStats.setRowCount(len(table_headers)) 77 | self.tableSetStats.setVerticalHeaderLabels(table_headers) 78 | self.tableSetStats.verticalHeader().setDefaultSectionSize(40) 79 | self.tableSetStats.setColumnCount(6) 80 | 81 | # Set up plots for display 82 | # Create empty plot for y and velocity 83 | self.plotTimeline.clear() 84 | self.t1 = self.plotTimeline.plotItem 85 | self.t1.setLabel('bottom', 'Time (s)', **{'color': '#FFFFFF'}) 86 | self.t1.setLabel('left', 'Y (m)', **{'color': '#FFC914'}) 87 | self.t1.setLabel('right', 'Velocity (m/s)', **{'color': '#17BEEB'}) 88 | # Link X axis but keep y separate for y, velocity 89 | self.t2 = pg.ViewBox() 90 | self.t1.showAxis('right') 91 | self.t1.scene().addItem(self.t2) 92 | self.t1.getAxis('right').linkToView(self.t2) 93 | self.t2.setXLink(self.t1) 94 | self.update_timeline_view() 95 | self.t1.vb.sigResized.connect(self.update_timeline_view) 96 | # Create empty plot for barbell motion path 97 | self.plotMotion.clear() 98 | self.xy = self.plotMotion.plotItem 99 | self.xy.setLabel('bottom', 'X (m)', **{'color': '#FFFFFF'}) 100 | self.xy.setLabel('left', 'Y (m)', **{'color': '#FFFFFF'}) 101 | self.xy.setAspectLocked(lock=True, ratio=1) 102 | 103 | # Logic controls for button clicks 104 | self.selecting = False # Whether color selection window is open 105 | self.selecting_active = False # Whether mouse is currently selecting color 106 | self.tracking = False # Whether barbell tracking is ongoing for a set 107 | self.cropping = False # Whether the lifer is currently cropping 108 | 109 | # Globals that need sharing throughout the apps 110 | self.mask_colors = deque() 111 | self.smoothing_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) 112 | 113 | # Methods for initializing UI 114 | 115 | def load_settings(self): 116 | """ 117 | Loads settings back from previous session. 118 | """ 119 | settings_file = open('./resources/settings.json') 120 | settings = json.load(settings_file) 121 | settings_file.close() 122 | try: 123 | if settings['camera'] in self.camera_list: 124 | self.comboCamera.setCurrentIndex(settings['camera']) 125 | self.comboRotation.setCurrentIndex(settings['rotation']) 126 | self.spinMinHue.setValue(settings['colors']['min_hue']) 127 | self.spinMaxHue.setValue(settings['colors']['max_hue']) 128 | self.spinMinSaturation.setValue(settings['colors']['min_saturation']) 129 | self.spinMaxSaturation.setValue(settings['colors']['max_saturation']) 130 | self.spinMinValue.setValue(settings['colors']['min_value']) 131 | self.spinMaxValue.setValue(settings['colors']['max_value']) 132 | self.spinDiameter.setValue(settings['diameter']) 133 | self.lineEditLifter.setText(settings['lifter']) 134 | self.checkSaveVideo.setChecked(settings['save_video']) 135 | except KeyError: 136 | self.statusbar.clearMessage() 137 | self.statusbar.showMessage('Error in settings.json. Loading defaults instead.') 138 | 139 | def save_settings(self): 140 | """ 141 | Saves settings for a future session. 142 | """ 143 | settings = {'camera': self.comboCamera.currentIndex(), 144 | 'rotation': self.comboRotation.currentIndex(), 145 | 'colors': { 146 | 'min_hue': self.spinMinHue.value(), 147 | 'max_hue': self.spinMaxHue.value(), 148 | 'min_saturation': self.spinMinSaturation.value(), 149 | 'max_saturation': self.spinMaxSaturation.value(), 150 | 'min_value': self.spinMinValue.value(), 151 | 'max_value': self.spinMaxValue.value(), 152 | }, 'diameter': self.spinDiameter.value(), 153 | 'lifter': self.lineEditLifter.text(), 154 | 'save_video': self.checkSaveVideo.isChecked() 155 | } 156 | settings_file = open('./resources/settings.json', 'w') 157 | json.dump(settings, settings_file, indent=4) 158 | settings_file.close() 159 | self.statusbar.clearMessage() 160 | self.statusbar.showMessage('Settings saved.', 5000) 161 | 162 | def refresh_cameras(self): 163 | """ 164 | Searches for all available cameras and updates them in the UI. 165 | """ 166 | self.comboCamera.clear() 167 | self.camera_list = webcam.list_available_cameras() 168 | for c in self.camera_list: 169 | self.comboCamera.addItem(str(c)) 170 | 171 | # Methods for adapting UI 172 | 173 | def lbs_changed(self): 174 | """ 175 | Adapts kgs spinbox to a change in lbs. 176 | """ 177 | kgs = round(self.spinLbs.value() * 0.453592, 1) 178 | self.spinKgs.setValue(kgs) 179 | 180 | def kgs_changed(self): 181 | """ 182 | Adapts lbs spinbox to a change in kgs. 183 | """ 184 | lbs = round(self.spinKgs.value() * 2.20462, 1) 185 | self.spinLbs.setValue(lbs) 186 | 187 | def handle_color_selection(self, event, x, y, flags, frame): 188 | """ 189 | Processes mouse events in color selection cv2 window and adjusts UI logic accordingly. 190 | """ 191 | if event == cv2.EVENT_LBUTTONDOWN: 192 | self.mask_colors.append(frame[y, x].tolist()) 193 | self.selecting_active = True 194 | elif event == cv2.EVENT_MOUSEMOVE: 195 | if self.selecting_active is True: 196 | self.mask_colors.append(frame[y, x].tolist()) 197 | elif event == cv2.EVENT_LBUTTONUP: 198 | self.selecting_active = False 199 | elif event == cv2.EVENT_RBUTTONDOWN: 200 | cv2.destroyWindow('Masked') 201 | self.mask_colors = deque() 202 | self.reset_colors() 203 | 204 | def reset_colors(self): 205 | """ 206 | Resets the selection colors back to their full ranges. 207 | """ 208 | self.spinMinHue.setValue(0) 209 | self.spinMaxHue.setValue(180) 210 | self.spinMinSaturation.setValue(0) 211 | self.spinMaxSaturation.setValue(255) 212 | self.spinMinValue.setValue(0) 213 | self.spinMaxValue.setValue(255) 214 | 215 | def update_table(self, set_data, rep_stats): 216 | """ 217 | Clear the table and update it with stats from the current set. 218 | 219 | Parameters 220 | ---------- 221 | set_data : DataFrame 222 | Data from the analyzed log. Really only here because it's needed in the Movement combo boxes to pass through 223 | to self.edit_rep. 224 | rep_stats : Dictionary 225 | Dictionary containing metadata from the current set, including all of the measures to be viewed in the 226 | table. 227 | """ 228 | self.tableSetStats.setColumnCount(len(rep_stats.keys())) 229 | # Update table 230 | for r, rep in enumerate(rep_stats.keys()): 231 | 232 | # Add a combo box that allows the user to select whether they failed the rep, it was a false detection, 233 | # or reclassify the rep as a different movement 234 | cb = QtWidgets.QComboBox(parent=self.tableSetStats) 235 | cb.addItems(['FALSE', 'PARTIAL', 'FAIL']) 236 | cb.addItem(self.lifts[rep_stats[rep]['lift']]['name']) 237 | movements = self.lifts[rep_stats[rep]['lift']]['movements'] 238 | if movements == "all": 239 | cb.addItems([self.lifts[moves]['name'] for moves in self.lifts.keys()]) 240 | elif movements == "none": 241 | pass 242 | else: 243 | cb.addItems(movements) 244 | 245 | # Connect the signal to refresh the table and replace DB values when reps are edited 246 | cb.activated.connect(lambda: self.edit_rep(set_data, rep_stats)) 247 | 248 | # Make the default currently selected text equal to the name of the lift from lifts.json based on the 249 | # selected movement in the rep_stats dict 250 | if rep_stats[rep]['movement'] == 'false': 251 | cb.setCurrentIndex(0) 252 | elif rep_stats[rep]['movement'] == 'partial': 253 | cb.setCurrentIndex(1) 254 | elif rep_stats[rep]['movement'] == 'fail': 255 | cb.setCurrentIndex(2) 256 | else: 257 | cb.setCurrentIndex(cb.findText(self.lifts[rep_stats[rep]['movement']]['name'], 258 | QtCore.Qt.MatchFixedString)) 259 | 260 | # Create rows for displaying metrics 261 | self.tableSetStats.setCellWidget(0, r, cb) 262 | self.tableSetStats.setItem(1, r, QtWidgets.QTableWidgetItem(f"{rep_stats[rep]['average_velocity']:.2f}")) 263 | self.tableSetStats.setItem(2, r, QtWidgets.QTableWidgetItem(f"{rep_stats[rep]['peak_velocity']:.2f}")) 264 | self.tableSetStats.setItem(3, r, QtWidgets.QTableWidgetItem(f"{rep_stats[rep]['peak_power']:.2f}")) 265 | self.tableSetStats.setItem(4, r, QtWidgets.QTableWidgetItem(f"{rep_stats[rep]['peak_height']:.2f}")) 266 | self.tableSetStats.setItem(5, r, QtWidgets.QTableWidgetItem(f"{rep_stats[rep]['x_rom']:.2f}")) 267 | self.tableSetStats.setItem(6, r, QtWidgets.QTableWidgetItem(f"{rep_stats[rep]['y_rom']:.2f}")) 268 | self.tableSetStats.setItem(7, r, QtWidgets.QTableWidgetItem(f"{rep_stats[rep]['t_concentric']:.2f}")) 269 | 270 | # Update table colors 271 | if rep_stats[rep]['movement'] not in ['false', 'partial', 'fail']: 272 | comparator = rep_stats[rep][self.lifts[rep_stats[rep]['movement']]['pf_metric']] 273 | condition = self.lifts[rep_stats[rep]['movement']]['pf_criterion'] 274 | pass_rep = eval(f"{comparator}{condition}") 275 | if pass_rep is True: 276 | col_color = QtGui.QColor(COLOR_SCHEME['green']) 277 | else: 278 | col_color = QtGui.QColor(COLOR_SCHEME['orange']) 279 | for i in range(1, self.tableSetStats.rowCount()): 280 | self.tableSetStats.item(i, r).setBackground(col_color) 281 | else: 282 | pass 283 | 284 | def update_plots(self, set_data, rep_stats): 285 | """ 286 | Adapt timeline and motion plots to new log and analysis. 287 | 288 | Parameters 289 | ---------- 290 | set_data : DataFrame 291 | Data from the analyzed log. Must have columns for Time, X_m, Y_m, Velocity, and Reps. 292 | rep_stats : Dictionary 293 | Dictionary containing metadata from the current set, including all of the measures to be viewed in the 294 | table. 295 | """ 296 | # Update plots 297 | self.t2.clear() 298 | y_pen = pg.mkPen(color='#FFC914', width=1.5) 299 | v_pen = pg.mkPen(color='#17BEEB', width=1.5) 300 | self.t1.plot(set_data['Time'].values, set_data['Y_m'].values, pen=y_pen, clear=True) 301 | self.t2.addItem( 302 | pg.PlotCurveItem(set_data['Time'].values, set_data['Velocity'].values, pen=v_pen, clear=True)) 303 | 304 | m_pen = pg.mkPen(color='#FFFFFF', width=1) 305 | self.xy.plot(set_data['X_m'].values[20:], set_data['Y_m'].values[20:], pen=m_pen, clear=True) 306 | 307 | # Update rep highlighting in timeline and max velocity points in bar path 308 | reps_labeled, n_reps = label(set_data['Reps'].values) 309 | if n_reps != 0: 310 | for r in range(1, n_reps + 1): 311 | rep = f"rep{r}" 312 | if rep_stats[rep]['movement'] not in ['false', 'partial', 'fail']: 313 | idx = tuple([reps_labeled == r]) 314 | t_l = set_data['Time'].values[idx][0] 315 | t_r = set_data['Time'].values[idx][-1] 316 | comparator = rep_stats[rep][self.lifts[rep_stats[rep]['movement']]['pf_metric']] 317 | condition = self.lifts[rep_stats[rep]['movement']]['pf_criterion'] 318 | pass_rep = eval(f"{comparator}{condition}") 319 | if pass_rep is True: 320 | rep_color = COLOR_SCHEME['green'] 321 | else: 322 | rep_color = COLOR_SCHEME['orange'] 323 | lri_brush = pg.mkBrush(color=rep_color) 324 | lri_pen = pg.mkPen(color=rep_color) 325 | lri = pg.LinearRegionItem((t_l, t_r), brush=lri_brush, pen=lri_pen, movable=False) 326 | lri.setOpacity(0.3) 327 | rep_lri_label = self.lifts[rep_stats[rep]['movement']]['name'] 328 | ti = pg.TextItem(text=rep_lri_label, color='#FFFFFF', anchor=(0.5, 0.5)) 329 | rep_lri_pos = self.t1.getAxis('left').range[1] * 0.9 330 | ti.setPos((t_r + t_l) / 2, rep_lri_pos) 331 | self.t1.addItem(lri) 332 | self.t1.addItem(ti) 333 | max_y = rep_stats[rep]['peak_height'] 334 | max_x = set_data['X_m'].values[idx][np.where(set_data['Y_m'].values[idx] == max_y)] 335 | self.xy.addItem( 336 | pg.ScatterPlotItem(x=[max_x], y=[max_y], symbol='o', pen=lri_pen, brush=lri_brush, size=12)) 337 | 338 | def edit_rep(self, set_data, rep_stats): 339 | """ 340 | Adjusts rep metadata based on edits made in the table, then refreshes the table and updates the database 341 | accordingly. 342 | 343 | Parameters 344 | ---------- 345 | set_data : DataFrame 346 | Data from the analyzed log. Must have columns for Time, X_m, Y_m, Velocity, and Reps. 347 | rep_stats : Dictionary 348 | Dictionary containing metadata from the current set. 349 | """ 350 | cb = self.sender() 351 | ix = self.tableSetStats.indexAt(cb.pos()) 352 | rep_stats[f"rep{ix.column()+1}"]['movement'] = cb.currentText().lower().replace(' ', '') 353 | self.update_table(set_data, rep_stats) 354 | self.update_plots(set_data, rep_stats) 355 | database.update_rep_history(DB_PATH, rep_stats) 356 | 357 | def update_timeline_view(self): 358 | """ 359 | Needed so that when plot is resized, geometries of overlaid PlotItem and PlotCurveItem are handled correctly. 360 | """ 361 | self.t2.setGeometry(self.t1.vb.sceneBoundingRect()) 362 | self.t2.linkedViewChanged(self.t1.vb, self.t2.XAxis) 363 | 364 | # Menu actions 365 | 366 | def export_database(self): 367 | """ 368 | Writes set and rep history to a single Excel file with multiple sheets. User chooses file location on disk. 369 | """ 370 | base_path = QtWidgets.QFileDialog.getSaveFileName(self, 'Save File', filter='CSV (*.csv)') 371 | database.export_to_csv(DB_PATH, base_path[0]) 372 | 373 | def show_documentation(self): 374 | """ 375 | Loads and displays simple HTML documentation. 376 | """ 377 | self.docs = documentation.Documentation() 378 | 379 | # Button actions 380 | 381 | def preview_camera(self): 382 | """ 383 | Launches a stream of the webcam to allow the lifter to see what framing, rotation, and cropping look like. 384 | """ 385 | self.statusbar.clearMessage() 386 | self.statusbar.showMessage('Previewing the camera. Press the Enter key to exit.') 387 | self.buttonPreview.setText('Press Enter\nto finish.') 388 | self.comboCamera.setEnabled(False) 389 | self.buttonSelectColor.setEnabled(False) 390 | self.buttonLogSet.setEnabled(False) 391 | cap = webcam.initiate_camera(self.comboCamera.currentIndex()) 392 | while True: 393 | _, frame = cap.read() 394 | frame = np.rot90(frame, self.comboRotation.currentIndex()) 395 | cv2.imshow('Camera Preview', frame) 396 | key = cv2.waitKey(1) & 0xFF 397 | if key == ord('\r'): 398 | break 399 | cap.release() 400 | cv2.destroyAllWindows() 401 | self.buttonPreview.setText('Preview') 402 | self.comboCamera.setEnabled(True) 403 | self.buttonSelectColor.setEnabled(True) 404 | self.buttonLogSet.setEnabled(True) 405 | self.statusbar.clearMessage() 406 | 407 | def select_colors(self): 408 | """ 409 | Launches a stream of the webcam to allow the lifter to select the color of the barbell marker. 410 | """ 411 | self.statusbar.clearMessage() 412 | self.statusbar.showMessage('Select colors of the marker using the left mouse button. Right click to clear. ' 413 | 'Press the Enter key to confirm.') 414 | self.buttonSelectColor.setText('Press Enter\nto finish.') 415 | self.buttonPreview.setEnabled(False) 416 | self.buttonLogSet.setEnabled(False) 417 | self.selecting = True 418 | n_90_rotations = self.comboRotation.currentIndex() 419 | cap = webcam.initiate_camera(self.comboCamera.currentIndex()) 420 | while True: 421 | _, frame = cap.read() 422 | frame = np.rot90(frame, n_90_rotations) 423 | cv2.imshow('Select Colors', frame) 424 | hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) 425 | cv2.setMouseCallback('Select Colors', self.handle_color_selection, hsv) 426 | if len(self.mask_colors) != 0: 427 | self.spinMinHue.setValue(min(c[0] for c in self.mask_colors)) 428 | self.spinMaxHue.setValue(max(c[0] for c in self.mask_colors)) 429 | self.spinMinSaturation.setValue(min(c[1] for c in self.mask_colors)) 430 | self.spinMaxSaturation.setValue(max(c[1] for c in self.mask_colors)) 431 | self.spinMinValue.setValue(min(c[2] for c in self.mask_colors)) 432 | self.spinMaxValue.setValue(max(c[2] for c in self.mask_colors)) 433 | lower = np.array([self.spinMinHue.value(), self.spinMinSaturation.value(), self.spinMinValue.value()]) 434 | upper = np.array([self.spinMaxHue.value(), self.spinMaxSaturation.value(), self.spinMaxValue.value()]) 435 | masked = cv2.bitwise_and(frame, frame, 436 | mask=analyze.apply_mask(frame, lower, upper, self.smoothing_kernel)) 437 | cv2.imshow('Masked', masked) 438 | key = cv2.waitKey(1) & 0xFF 439 | if key == ord('\r'): 440 | break 441 | cap.release() 442 | cv2.destroyAllWindows() 443 | self.selecting = False 444 | self.buttonPreview.setEnabled(True) 445 | self.buttonLogSet.setEnabled(True) 446 | self.buttonSelectColor.setText('Select Color') 447 | self.statusbar.clearMessage() 448 | 449 | def log_set(self): 450 | """ 451 | Records a video of a set for further analysis. 452 | 453 | Opens a stream to the webcam and rotates each frame appropriately, then displays the stream. 454 | 455 | Note that this saves to 30 fps regardless of the actual frame rate since cv2.VideoWriter requires a frame rate 456 | when it's instantiated. 457 | """ 458 | # Adapt the UI 459 | self.statusbar.clearMessage() 460 | self.statusbar.showMessage('Recording your set. Press the Enter key when you are finished. The apps will then ' 461 | 'hang for a few seconds to process before showing results.') 462 | self.buttonLogSet.setText('Press Enter\nto finish.') 463 | self.buttonPreview.setEnabled(False) 464 | self.buttonSelectColor.setEnabled(False) 465 | self.tracking = True 466 | 467 | # Prepare set metadata 468 | set_id = time.strftime('%y%m%d-%H%M%S') 469 | exercise = self.comboExercise.currentText().lower().replace(' ', '') 470 | if self.checkSaveVideo.isChecked(): 471 | video_file = os.path.join(DATA_DIR, f"{set_id}_{exercise}.mp4") 472 | else: 473 | video_file = "Video not saved" 474 | log_file = os.path.join(DATA_DIR, f"{set_id}_{exercise}.csv") 475 | set_stats = {'set_id': set_id, 476 | # 'raw_video_file': video_file, 477 | # 'log_file': log_file, 478 | 'lifter': self.lineEditLifter.text(), 479 | 'lift': self.comboExercise.currentText().lower().replace(' ', ''), 480 | 'weight': self.spinKgs.value(), 481 | 'nominal_diameter': self.spinDiameter.value(), 482 | 'pixel_calibration': -1.0} 483 | 484 | # Initialize 485 | n_90_rotations = self.comboRotation.currentIndex() 486 | n_frames = 0 487 | path_time = np.array([], dtype=np.float32) 488 | path_x = np.array([], dtype=np.float32) 489 | path_y = np.array([], dtype=np.float32) 490 | path_radii = np.array([], dtype=np.float32) 491 | 492 | # Camera setup 493 | cap = webcam.initiate_camera(self.comboCamera.currentIndex()) 494 | time.sleep(2) 495 | if n_90_rotations in [0, 2]: 496 | width = int(cap.get(3)) 497 | height = int(cap.get(4)) 498 | else: 499 | width = int(cap.get(4)) 500 | height = int(cap.get(3)) 501 | if self.checkSaveVideo.isChecked(): 502 | video_out = cv2.VideoWriter(video_file, cv2.VideoWriter_fourcc(*'mp4v'), 30, (width, height)) 503 | start_time = time.time() 504 | while True: 505 | _, frame = cap.read() 506 | frame = cv2.UMat(np.rot90(frame, n_90_rotations)) 507 | lower = np.array([self.spinMinHue.value(), self.spinMinSaturation.value(), self.spinMinValue.value()]) 508 | upper = np.array([self.spinMaxHue.value(), self.spinMaxSaturation.value(), self.spinMaxValue.value()]) 509 | masked = analyze.apply_mask(frame, lower, upper, self.smoothing_kernel) 510 | contours, _ = cv2.findContours(masked, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) 511 | if len(contours) != 0: 512 | largest = max(contours, key=cv2.contourArea) 513 | (x, y), radius = cv2.minEnclosingCircle(largest) 514 | if radius > 5: # Reject small circles so noise can't warp bar path 515 | cv2.circle(frame, (int(x), int(y)), int(radius), (0, 255, 0), -1) 516 | path_time = np.append(path_time, (time.time() - start_time)) 517 | path_x = np.append(path_x, x) 518 | path_y = np.append(path_y, y) 519 | path_radii = np.append(path_radii, radius) 520 | else: 521 | cv2.circle(frame, (int(x), int(y)), int(radius), (0, 0, 255), -1) 522 | cv2.imshow('Tracking barbell...', frame) 523 | if self.checkSaveVideo.isChecked(): 524 | video_out.write(frame) 525 | n_frames += 1 526 | key = cv2.waitKey(1) & 0xFF 527 | if key == 27: 528 | self.tracking = False 529 | cap.release() 530 | if self.checkSaveVideo.isChecked(): 531 | video_out.release() 532 | cv2.destroyAllWindows() 533 | self.buttonLogSet.setText('Log Set') 534 | self.buttonPreview.setEnabled(True) 535 | self.buttonSelectColor.setEnabled(True) 536 | return 537 | if key == ord('\r'): 538 | self.tracking = False 539 | if self.tracking is False: 540 | break 541 | 542 | # Release hold on camera and write video 543 | cap.release() 544 | if self.checkSaveVideo.isChecked(): 545 | video_out.release() 546 | cv2.destroyAllWindows() 547 | self.buttonLogSet.setText('Log Set') 548 | self.buttonPreview.setEnabled(True) 549 | self.buttonSelectColor.setEnabled(True) 550 | 551 | # Do the actual analysis 552 | # First, correct Y for video height since Y increases going DOWN 553 | path_y = height - path_y 554 | set_data, set_stats['pixel_calibration'] = analyze.analyze_set(path_time, path_x, path_y, 555 | path_radii, self.spinDiameter.value()) 556 | set_data.to_csv(log_file) 557 | self.lineEditLogPath.setText(os.path.abspath(log_file)) 558 | 559 | # Convert the video to the correct framerate and trace the bar path 560 | # Removing for now - major bottleneck in speed and tracing is not correct 561 | # analyze.post_process_video(video_file, n_frames, set_data) 562 | 563 | # Compute stats for each rep and update set stats with number of reps 564 | set_stats, rep_stats = analyze.analyze_reps(set_data, set_stats, self.lifts) 565 | set_stats['rep_stats'] = rep_stats 566 | 567 | # Update the database 568 | database.update_set_history(DB_PATH, set_stats) 569 | database.update_rep_history(DB_PATH, rep_stats) 570 | 571 | # Update the table and plots 572 | self.update_table(set_data, rep_stats) 573 | self.update_plots(set_data, rep_stats) 574 | 575 | # Adjust UI back 576 | self.statusbar.clearMessage() 577 | self.statusbar.showMessage('Analysis complete!', 5000) 578 | 579 | def closeEvent(self, event): 580 | """ 581 | Offer lifter opportunity to cancel closing. 582 | If lifter does want to close, save current settings to a json file. 583 | """ 584 | quit_message = 'Are you sure you want to quit?' 585 | reply = QtWidgets.QMessageBox.question(self, 'Message', quit_message, 586 | QtWidgets.QMessageBox.Yes, QtWidgets.QMessageBox.No) 587 | if reply == QtWidgets.QMessageBox.Yes: 588 | self.save_settings() 589 | event.accept() 590 | else: 591 | event.ignore() 592 | 593 | # Methods for utility 594 | --------------------------------------------------------------------------------