Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MVP for the DLC webinar #5

Open
wants to merge 30 commits into
base: webinar
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
2bb91c2
add file path
stes Jun 4, 2020
de8dd94
stes/webinar
stes Jun 5, 2020
c11e5a0
refactor
stes Jun 5, 2020
7510314
Add MVC layout; disable dragging
stes Jun 5, 2020
c615677
fix layout
stes Jun 5, 2020
e440231
Fix dockerfile
stes Jun 5, 2020
34e01c9
Update image; add data fetching
stes Jun 5, 2020
ad21ce6
Data preparation script
stes Jun 5, 2020
723d038
automatic app update on server
stes Jun 5, 2020
f1ced91
Fix missing csv column
stes Jun 5, 2020
7930b8f
Update view.py
MMathisLab Jun 5, 2020
744fc72
Merge pull request #6 from DeepLabCut/MMathisLab-patch-1
stes Jun 5, 2020
6c03c4f
Merge branch 'webinar' into stes/webinar
jeylau Jun 5, 2020
5ebf034
Re-enable draggable mode and clean imports
jeylau Jun 5, 2020
6723d02
And this one...
jeylau Jun 5, 2020
ae4856a
Update slider range
jeylau Jun 5, 2020
40c29b5
Retain only a subset of keypoints
jeylau Jun 5, 2020
72da802
Auto-formatting
jeylau Jun 5, 2020
c8fcb76
Minor fix
jeylau Jun 5, 2020
d42402c
Add default random ID
jeylau Jun 5, 2020
95730fc
Shuffle keypoints when changing image
jeylau Jun 5, 2020
528f78e
Putative gain in speed upon fetching images
jeylau Jun 5, 2020
899f650
Store data when changing image
jeylau Jun 5, 2020
d915c2c
Shuffle fetched images
jeylau Jun 5, 2020
4fd4ec3
Quick patch missing bodypart
jeylau Jun 5, 2020
a2b122e
Fix empty ID
jeylau Jun 5, 2020
488d486
rm options
stes Jun 5, 2020
8c63c9f
final fix for webinar
stes Jun 5, 2020
a36bd3e
fix annottion adding
stes Jun 5, 2020
e7f92e0
creating filelist
AlexEMG Jun 9, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
**
!*.py
!static/
!config/
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
data/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
Expand Down
20 changes: 20 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
FROM tiangolo/uvicorn-gunicorn-fastapi:python3.8

RUN /usr/local/bin/python -m pip install --upgrade pip
RUN pip install --no-cache-dir \
dash \
plotly \
scikit-image \
pandas \
gunicorn
RUN pip install --no-cache-dir requests

RUN mkdir -p /app
RUN mkdir -p /app/static
WORKDIR /app

ADD config/ /app/config
ADD *.py /app/
ADD static/ /app/static/

ENTRYPOINT ["gunicorn", "-w", "1", "-b", "0.0.0.0:8050", "app:server"]
Empty file added __init__.py
Empty file.
268 changes: 91 additions & 177 deletions app.py
Original file line number Diff line number Diff line change
@@ -1,213 +1,121 @@
import flask
import dash
import dash_core_components as dcc
import dash_html_components as html
from dash.dependencies import Input, Output, State
import base64
import json
import os
import matplotlib.cm
import matplotlib.colors as mcolors
import numpy as np
import random
import plotly.express as px
from skimage import data, transform


COLORMAP = 'plasma'
KEYPOINTS = ['Nose', 'L_Eye', 'R_Eye', 'L_Ear', 'R_Ear', 'Throat',
'Withers', 'TailSet', 'L_F_Paw', 'R_F_Paw', 'L_F_Wrist',
'R_F_Wrist', 'L_F_Elbow', 'R_F_Elbow', 'L_B_Paw', 'R_B_Paw',
'L_B_Hock', 'R_B_Hock', 'L_B_Stiffle', 'R_B_Stiffle']
N_SUBSET = 3
IMAGE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'full_dog.png')
encoded_image = base64.b64encode(open(IMAGE_PATH, 'rb').read())

img = data.chelsea()
img = img[::2, ::2]
images = [img, img[::-1], transform.rotate(img, 30)]
cmap = matplotlib.cm.get_cmap(COLORMAP, N_SUBSET)


def make_figure_image(i):
fig = px.imshow(images[i % len(images)])
fig.layout.xaxis.showticklabels = False
fig.layout.yaxis.showticklabels = False
fig.update_traces(hoverinfo='none', hovertemplate='')
return fig


def draw_circle(center, radius, n_points=50):
pts = np.linspace(0, 2 * np.pi, n_points)
x = center[0] + radius * np.cos(pts)
y = center[1] + radius * np.sin(pts)
path = 'M ' + str(x[0]) + ',' + str(y[1])
for k in range(1, x.shape[0]):
path += ' L ' + str(x[k]) + ',' + str(y[k])
path += ' Z'
return path


def compute_circle_center(path):
"""
See Eqn 1 & 2 pp.12-13 in REGRESSIONS CONIQUES, QUADRIQUES
Régressions linéaires et apparentées, circulaire, sphérique
Jacquelin J., 2009.
"""
coords = [list(map(float, coords.split(','))) for coords in path.split(' ')[1::2]]
x, y = np.array(coords).T
n = len(x)
sum_x = np.sum(x)
sum_y = np.sum(y)
sum_x2 = np.sum(x * x)
sum_y2 = np.sum(y * y)
delta11 = n * np.dot(x, y) - sum_x * sum_y
delta20 = n * sum_x2 - sum_x ** 2
delta02 = n * sum_y2 - sum_y ** 2
delta30 = n * np.sum(x ** 3) - sum_x2 * sum_x
delta03 = n * np.sum(y ** 3) - sum_y * sum_y2
delta21 = n * np.sum(x * x * y) - sum_x2 * sum_y
delta12 = n * np.sum(x * y * y) - sum_x * sum_y2

# Eqn 2, p.13
num_a = (delta30 + delta12) * delta02 - (delta03 + delta21) * delta11
num_b = (delta03 + delta21) * delta20 - (delta30 + delta12) * delta11
den = 2 * (delta20 * delta02 - delta11 * delta11)
a = num_a / den
b = num_b / den
return a, b


def get_plotly_color(n):
return mcolors.to_hex(cmap(n))


fig = make_figure_image(0)

external_stylesheets = ['https://codepen.io/chriddyp/pen/bWLwgP.css']

app = dash.Dash(__name__, external_stylesheets=external_stylesheets)
server = app.server

options = random.sample(KEYPOINTS, N_SUBSET)

styles = {
'pre': {
'border': 'thin lightgrey solid',
'overflowX': 'scroll'
}
}

app.layout = html.Div([
html.Div([
dcc.Graph(
id='canvas',
config={'editable': True},
figure=fig)
],
className="six columns"
),
html.Div([
html.H2("Controls"),
dcc.RadioItems(id='radio',
options=[{'label': opt, 'value': opt} for opt in options],
value=options[0]
),
html.Button('Previous', id='previous'),
html.Button('Next', id='next'),
html.Button('Clear', id='clear'),
html.Button('Save', id='save'),
dcc.Store(id='store', data=0),
html.P([
html.Label('Keypoint label size'),
dcc.Slider(id='slider',
min=3,
max=36,
step=1,
value=12)
], style={'width': '80%',
'display': 'inline-block'})
],
className="six columns"
),
html.Div([
dcc.Markdown("""
**Instructions**\n
Click on the image to add a keypoint.
"""),
html.Pre(id='click-data', style=styles['pre']),
html.Img(src='data:image/png;charset=utf-8;base64,{}'.format(encoded_image))
],
className='six columns'
),
html.Div(id='placeholder', style={'display': 'none'}),
html.Div(id='shapes', style={'display': 'none'})
]
)
import utils, model, view
from config import Config


__version__ = "0.1"

print(f"| Starting version {__version__}")

config = Config('config/config.json')
db = model.AppModel(config=config)
cmap = matplotlib.cm.get_cmap('plasma', len(config.options))
server = flask.Flask(__name__)
view = view.AppView(__name__, db=db, config=config, server=server)


@server.route('/csv/')
def fetch_csv():
return db.to_csv()


@server.route('/overview/')
def fetch_html():
return db.to_html()


@app.callback(Output('placeholder', 'children'),
@view.app.callback(Output('placeholder', 'children'),
[Input('save', 'n_clicks')],
[State('store', 'data')])
def save_data(click_s, ind_image):
if click_s:
xy = {shape.name: compute_circle_center(shape.path) for shape in fig.layout.shapes}
xy = {shape.name: utils.compute_circle_center(shape.path) for shape in view.fig.layout.shapes}
print(xy, ind_image)


@app.callback(
@view.app.callback(
Output('radio', 'options'),
[Input('next', 'n_clicks'),
Input('previous', 'n_clicks')]
)
def refresh_radio_buttons(click_n, click_p):
if not (click_n or click_p):
return dash.no_update
return view.refresh_radio_buttons()


def store_data(db, username, shapes):
for shape in shapes:
db.add_annotation(
name=shape.name, username=username,
xy=utils.compute_circle_center(shape.path)
)


@view.app.callback(
[Output('canvas', 'figure'),
Output('radio', 'value'),
Output('store', 'data'),
Output('shapes', 'children')],
Output('shapes', 'children'),
],
[Input('canvas', 'clickData'),
Input('canvas', 'relayoutData'),
Input('next', 'n_clicks'),
Input('previous', 'n_clicks'),
Input('clear', 'n_clicks'),
Input('slider', 'value')],
Input('slider', 'value'),
Input('input_name', 'value')
],
[State('canvas', 'figure'),
State('radio', 'value'),
State('store', 'data'),
State('shapes', 'children')]
)
def update_image(clickData, relayoutData, click_n, click_p, click_c, slider_val,
def update_image(clickData, relayoutData, click_n, click_p, click_c, slider_val, username,
figure, option, ind_image, shapes):

# TODO Refactor: Remove if/else statements and instead write multiple
# callbacks.
if not any(event for event in (clickData, click_n, click_p, click_c)):
return dash.no_update, dash.no_update, dash.no_update, dash.no_update

if ind_image is None:
ind_image = 0


if ind_image is None: ind_image = 0
shapes = [] if shapes is None else json.loads(shapes)
n_bpt = options.index(option)

ctx = dash.callback_context
event = ctx.triggered[0]['prop_id']
button_id = event.split('.')[0]
if button_id == 'clear':
fig.layout.shapes = []
fig.layout.xaxis.autorange = True
fig.layout.yaxis.autorange = 'reversed'
return make_figure_image(ind_image), options[0], ind_image, '[]'
elif button_id == 'next':
ind_image = (ind_image + 1) % len(images)
return make_figure_image(ind_image), options[0], ind_image, '[]'
elif button_id == 'previous':
ind_image = (ind_image - 1) % len(images)
return make_figure_image(ind_image), options[0], ind_image, '[]'
elif button_id == 'slider':
if button_id == 'slider':
for i in range(len(shapes)):
center = compute_circle_center(shapes[i]['path'])
new_path = draw_circle(center, slider_val)
center = utils.compute_circle_center(shapes[i]['path'])
new_path = utils.draw_circle(center, slider_val)
shapes[i]['path'] = new_path
elif button_id in ['clear', 'next', 'previous']:
if button_id == 'clear':
view.fig.layout.shapes = []
view.fig.layout.xaxis.autorange = True
view.fig.layout.yaxis.autorange = 'reversed'
elif button_id == 'next':
ind_image = (ind_image + 1) % len(db.dataset)
store_data(db, username, view.fig.layout.shapes)
elif button_id == 'previous':
ind_image = (ind_image - 1) % len(db.dataset)
store_data(db, username, view.fig.layout.shapes)
return view.make_figure_image(ind_image), view.options[0], ind_image, '[]'

already_labeled = [shape['name'] for shape in shapes]
key = list(relayoutData)[0]
keys = list(relayoutData) if relayoutData else []
key = keys[0] if len(keys) > 0 else ""
if option not in already_labeled and button_id != 'slider' and 'relayout' not in event:
if clickData:
x, y = clickData['points'][0]['x'], clickData['points'][0]['y']
circle = draw_circle((x, y), slider_val)
color = get_plotly_color(n_bpt)
circle = utils.draw_circle((x, y), slider_val)
ind_bpt = config.options.index(option)
color = utils.get_plotly_color(cmap, ind_bpt)
shape = dict(type='path',
path=circle,
line_color=color,
Expand All @@ -216,29 +124,35 @@ def update_image(clickData, relayoutData, click_n, click_p, click_c, slider_val,
opacity=0.8,
name=option)
shapes.append(shape)
#db.add_annotation(
# name=option, username=username,
# xy=utils.compute_circle_center(circle)
#)
else:
if 'path' in key and button_id != 'slider':
ind_moving = int(key.split('[')[1].split(']')[0])
path = relayoutData.pop(key)
shapes[ind_moving]['path'] = path
fig.update_layout(shapes=shapes)
view.fig.update_layout(shapes=shapes)

if 'range[' in key and 'clickData' not in event:
xrange = relayoutData['xaxis.range[0]'], relayoutData['xaxis.range[1]']
yrange = sorted((relayoutData['yaxis.range[0]'], relayoutData['yaxis.range[1]']),
reverse=True)
fig.update_xaxes(range=xrange, autorange=False)
fig.update_yaxes(range=yrange, autorange=False)
view.fig.update_xaxes(range=xrange, autorange=False)
view.fig.update_yaxes(range=yrange, autorange=False)
elif 'autorange' in key:
fig.update_xaxes(autorange=True)
fig.update_yaxes(autorange='reversed')
view.fig.update_xaxes(autorange=True)
view.fig.update_yaxes(autorange='reversed')
n_bpt = view.options.index(option) if option in view.options else 0
if button_id != 'slider' and 'relayout' not in event:
n_bpt += 1
new_option = options[min(len(options) - 1, n_bpt)]
return ({'data': figure['data'], 'layout': fig['layout']},
new_option = view.options[min(len(view.options) - 1, n_bpt)]
return ({'data': figure['data'], 'layout': view.fig['layout']},
new_option,
ind_image,
json.dumps(shapes))


if __name__ == '__main__':
app.run_server(debug=False, port=8051)
view.app.run_server(debug=False, port=8051)
13 changes: 13 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import json
import os.path as osp
import glob


class Config:
def __init__(self, config):
assert osp.exists(config)

with open(config, "r") as fp:
for key, val in json.load(fp).items():
setattr(self, key, val)
self.fnames = glob.glob('data/*.png')
Loading