├── .gitignore ├── CODE_OF_CONDUCT.md ├── Procfile ├── README.md ├── app.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at alexander.mathis@bethgelab.org. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn app:server 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DeepLabCut-WebApp 2 | 3 | A playground for a web-based labeling tool for [DeepLabCut](http://www.mousemotorlab.org/deeplabcut). Started at the do-a-thon as part of the Chan Zuckerberg Initiative's Essential Open Source Software for Science program's kickoff meeting in Berkeley, CA. (Feb 2020). More to come, feel free to contribute! 4 | 5 | This version comprises a web applications using [dash and plotly](https://dash.plotly.com/). 6 | 7 | # Installation & running 8 | 9 | ``` 10 | pip3 install -r requirements.txt 11 | python3 app.py 12 | ``` 13 | Once locally deployed, simply open the app via the URL in your browser. 14 | 15 | # Running a cloud instance 16 | 17 | Simply click on: 18 | 19 | https://deeplabcut.herokuapp.com/ 20 | 21 | This instance is based on an earlier version of the [app](https://github.com/DeepLabCut/DeepLabCut-WebApp/commit/3fbda43d1ed14fa1d59abe97f054c93dd0aec34b): 22 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import dash 2 | import dash_core_components as dcc 3 | import dash_html_components as html 4 | from dash.dependencies import Input, Output, State 5 | import base64 6 | import json 7 | import os 8 | import matplotlib.cm 9 | import matplotlib.colors as mcolors 10 | import numpy as np 11 | import random 12 | import plotly.graph_objects as go 13 | import plotly.express as px 14 | from skimage import data, transform 15 | 16 | 17 | COLORMAP = 'plasma' 18 | KEYPOINTS = ['Nose', 'L_Eye', 'R_Eye', 'L_Ear', 'R_Ear', 'Throat', 19 | 'Withers', 'TailSet', 'L_F_Paw', 'R_F_Paw', 'L_F_Wrist', 20 | 'R_F_Wrist', 'L_F_Elbow', 'R_F_Elbow', 'L_B_Paw', 'R_B_Paw', 21 | 'L_B_Hock', 'R_B_Hock', 'L_B_Stiffle', 'R_B_Stiffle'] 22 | N_SUBSET = 3 23 | 24 | img = data.chelsea() 25 | img = img[::2, ::2] 26 | images = [img, img[::-1], transform.rotate(img, 30)] 27 | cmap = matplotlib.cm.get_cmap(COLORMAP, N_SUBSET) 28 | 29 | 30 | def make_figure_image(i): 31 | fig = px.imshow(images[i % len(images)]) 32 | fig.layout.xaxis.showticklabels = False 33 | fig.layout.yaxis.showticklabels = False 34 | fig.update_traces(hoverinfo='none', hovertemplate='') 35 | return fig 36 | 37 | 38 | def draw_circle(center, radius, n_points=50): 39 | pts = np.linspace(0, 2 * np.pi, n_points) 40 | x = center[0] + radius * np.cos(pts) 41 | y = center[1] + radius * np.sin(pts) 42 | path = 'M ' + str(x[0]) + ',' + str(y[1]) 43 | for k in range(1, x.shape[0]): 44 | path += ' L ' + str(x[k]) + ',' + str(y[k]) 45 | path += ' Z' 46 | return path 47 | 48 | 49 | def compute_circle_center(path): 50 | """ 51 | See Eqn 1 & 2 pp.12-13 in REGRESSIONS CONIQUES, QUADRIQUES 52 | Régressions linéaires et apparentées, circulaire, sphérique 53 | Jacquelin J., 2009. 54 | """ 55 | coords = [list(map(float, coords.split(','))) for coords in path.split(' ')[1::2]] 56 | x, y = np.array(coords).T 57 | n = len(x) 58 | sum_x = np.sum(x) 59 | sum_y = np.sum(y) 60 | sum_x2 = np.sum(x * x) 61 | sum_y2 = np.sum(y * y) 62 | delta11 = n * np.dot(x, y) - sum_x * sum_y 63 | delta20 = n * sum_x2 - sum_x ** 2 64 | delta02 = n * sum_y2 - sum_y ** 2 65 | delta30 = n * np.sum(x ** 3) - sum_x2 * sum_x 66 | delta03 = n * np.sum(y ** 3) - sum_y * sum_y2 67 | delta21 = n * np.sum(x * x * y) - sum_x2 * sum_y 68 | delta12 = n * np.sum(x * y * y) - sum_x * sum_y2 69 | 70 | # Eqn 2, p.13 71 | num_a = (delta30 + delta12) * delta02 - (delta03 + delta21) * delta11 72 | num_b = (delta03 + delta21) * delta20 - (delta30 + delta12) * delta11 73 | den = 2 * (delta20 * delta02 - delta11 * delta11) 74 | a = num_a / den 75 | b = num_b / den 76 | return a, b 77 | 78 | 79 | def get_plotly_color(n): 80 | return mcolors.to_hex(cmap(n)) 81 | 82 | 83 | fig = make_figure_image(0) 84 | 85 | external_stylesheets = ['https://codepen.io/chriddyp/pen/bWLwgP.css'] 86 | 87 | app = dash.Dash(__name__, external_stylesheets=external_stylesheets) 88 | server = app.server 89 | 90 | options = random.sample(KEYPOINTS, N_SUBSET) 91 | 92 | styles = { 93 | 'pre': { 94 | 'border': 'thin lightgrey solid', 95 | 'overflowX': 'scroll' 96 | } 97 | } 98 | 99 | app.layout = html.Div([ 100 | html.Div([ 101 | dcc.Graph( 102 | id='canvas', 103 | config={'editable': True}, 104 | figure=fig) 105 | ], 106 | className="six columns" 107 | ), 108 | html.Div([ 109 | html.H2("Controls"), 110 | dcc.RadioItems(id='radio', 111 | options=[{'label': opt, 'value': opt} for opt in options], 112 | value=options[0] 113 | ), 114 | html.Button('Previous', id='previous'), 115 | html.Button('Next', id='next'), 116 | html.Button('Clear', id='clear'), 117 | html.Button('Save', id='save'), 118 | dcc.Store(id='store', data=0), 119 | html.P([ 120 | html.Label('Keypoint label size'), 121 | dcc.Slider(id='slider', 122 | min=3, 123 | max=36, 124 | step=1, 125 | value=12) 126 | ], style={'width': '80%', 127 | 'display': 'inline-block'}) 128 | ], 129 | className="six columns" 130 | ), 131 | html.Div([ 132 | dcc.Markdown(""" 133 | **Instructions**\n 134 | Click on the image to add a keypoint. 135 | """), 136 | html.Pre(id='click-data', style=styles['pre']), 137 | ], 138 | className='six columns' 139 | ), 140 | html.Div(id='placeholder', style={'display': 'none'}), 141 | html.Div(id='shapes', style={'display': 'none'}) 142 | ] 143 | ) 144 | 145 | 146 | @app.callback(Output('placeholder', 'children'), 147 | [Input('save', 'n_clicks')], 148 | [State('store', 'data')]) 149 | def save_data(click_s, ind_image): 150 | if click_s: 151 | xy = {shape.name: compute_circle_center(shape.path) for shape in fig.layout.shapes} 152 | print(xy, ind_image) 153 | 154 | 155 | @app.callback( 156 | [Output('canvas', 'figure'), 157 | Output('radio', 'value'), 158 | Output('store', 'data'), 159 | Output('shapes', 'children')], 160 | [Input('canvas', 'clickData'), 161 | Input('canvas', 'relayoutData'), 162 | Input('next', 'n_clicks'), 163 | Input('previous', 'n_clicks'), 164 | Input('clear', 'n_clicks'), 165 | Input('slider', 'value')], 166 | [State('canvas', 'figure'), 167 | State('radio', 'value'), 168 | State('store', 'data'), 169 | State('shapes', 'children')] 170 | ) 171 | def update_image(clickData, relayoutData, click_n, click_p, click_c, slider_val, 172 | figure, option, ind_image, shapes): 173 | if not any(event for event in (clickData, click_n, click_p, click_c)): 174 | return dash.no_update, dash.no_update, dash.no_update, dash.no_update 175 | 176 | if ind_image is None: 177 | ind_image = 0 178 | 179 | if shapes is None: 180 | shapes = [] 181 | else: 182 | shapes = json.loads(shapes) 183 | n_bpt = options.index(option) 184 | 185 | ctx = dash.callback_context 186 | button_id = ctx.triggered[0]['prop_id'].split('.')[0] 187 | if button_id == 'clear': 188 | fig.layout.shapes = [] 189 | return make_figure_image(ind_image), options[0], ind_image, '[]' 190 | elif button_id == 'next': 191 | ind_image = (ind_image + 1) % len(images) 192 | return make_figure_image(ind_image), options[0], ind_image, '[]' 193 | elif button_id == 'previous': 194 | ind_image = (ind_image - 1) % len(images) 195 | return make_figure_image(ind_image), options[0], ind_image, '[]' 196 | elif button_id == 'slider': 197 | for i in range(len(shapes)): 198 | center = compute_circle_center(shapes[i]['path']) 199 | new_path = draw_circle(center, slider_val) 200 | shapes[i]['path'] = new_path 201 | 202 | already_labeled = [shape['name'] for shape in shapes] 203 | key = list(relayoutData)[0] 204 | if option not in already_labeled and button_id != 'slider': 205 | if clickData: 206 | x, y = clickData['points'][0]['x'], clickData['points'][0]['y'] 207 | circle = draw_circle((x, y), slider_val) 208 | color = get_plotly_color(n_bpt) 209 | shape = dict(type='path', 210 | path=circle, 211 | line_color=color, 212 | fillcolor=color, 213 | layer='above', 214 | opacity=0.8, 215 | name=option) 216 | shapes.append(shape) 217 | else: 218 | if 'path' in key and button_id != 'slider': 219 | ind_moving = int(key.split('[')[1].split(']')[0]) 220 | path = relayoutData.pop(key) 221 | shapes[ind_moving]['path'] = path 222 | fig.update_layout(shapes=shapes) 223 | if 'range[' in key: 224 | xrange = relayoutData['xaxis.range[0]'], relayoutData['xaxis.range[1]'] 225 | yrange = relayoutData['yaxis.range[0]'], relayoutData['yaxis.range[1]'] 226 | fig.update_xaxes(range=xrange, autorange=False) 227 | fig.update_yaxes(range=yrange, autorange=False) 228 | elif 'autorange' in key: 229 | fig.update_xaxes(autorange=True) 230 | fig.update_yaxes(autorange=True) 231 | if button_id != 'slider': 232 | n_bpt += 1 233 | new_option = options[min(len(options) - 1, n_bpt)] 234 | return ({'data': figure['data'], 'layout': fig['layout']}, 235 | new_option, 236 | ind_image, 237 | json.dumps(shapes)) 238 | 239 | 240 | if __name__ == '__main__': 241 | app.run_server(debug=False, port=8051) 242 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | dash 2 | plotly 3 | scikit-image 4 | pandas 5 | gunicorn 6 | --------------------------------------------------------------------------------