├── 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 |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 |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 |36 | Reps are classified based on their pass/fail criteria: 37 |
38 |
39 | 40 | If a rep is completed but fails the success criterion, it is highlighted in red: 41 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/docs/editing_sets.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | 36 | Here we see an example of a false front squat rep from the unracking of the barbell: 37 |
38 |
39 | 40 | The user can correct this false rep by changing its "Movement" column value to "FALSE": 41 |
42 |
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 | 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 |
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 | 
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 | 
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 | 
47 |
48 | 
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 |