added new folder and ran tests

This commit is contained in:
Brian Bjarke Jensen
2024-05-07 20:19:18 +02:00
parent d9d2dbab74
commit bc653a0be4
16 changed files with 700 additions and 19 deletions
+57
View File
@@ -0,0 +1,57 @@
# build stage
FROM python:3.12-slim-bookworm as BUILDER
# set environment variables
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=off \
PIP_DISABLE_PIP_VERSION_CHECK=ON \
PIP_DEFAULT_TIMEOUT=100 \
DEBIAN_FRONTEND=noninteractive \
POETRY_HOME=/etc/poetry \
POETRY_VERSION=1.7.1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_NO_INTERACTION=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache \
APP_HOME=/home/app
# update system
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
curl \
&& apt-get clean
# install poetry
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="${POETRY_HOME}/bin:$PATH"
# install runtime dependencies
WORKDIR ${APP_HOME}
COPY poetry.lock pyproject.toml ./
RUN --mount=type=cache,target=${POETRY_CACHE_DIR} poetry install --without model
# final stage
FROM python:3.12-slim-bookworm
# copy virtualenv made by poetry
ENV APP_HOME=/home/app \
VIRTUAL_ENV=/home/app/.venv
COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
# create home directory and app user
RUN mkdir -p /home/app && \
addgroup --system app && \
adduser --system --group app
# add code while changing ownership
WORKDIR $APP_HOME
COPY --chown=app:app ./core ./core
COPY --chown=app:app ./src ./src
# change to the app user
USER app
ENTRYPOINT [ "gunicorn", "src.main:server", "-b", "0.0.0.0:8050" ]
+3
View File
@@ -0,0 +1,3 @@
from __future__ import annotations
from .app import app
+251
View File
@@ -0,0 +1,251 @@
from __future__ import annotations
import logging
import os
import dash_bootstrap_components as dbc
from dash import ALL
from dash import Dash
from dash import Input
from dash import Output
from dash import State
from dash_auth import BasicAuth
from pydantic import ValidationError
from .layout import app_layout
from core.database import connect
from core.database import count_documents
from core.database import get_visual_communication
from core.database import NoDocumentFoundException
from core.database import upsert_annotation
from core.database import upsert_visual_communication
from core.database import VisualCommunication
from core.dto import ModelData
# setup app
app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])
app.title = 'visual critical discourse analysis'.title()
app.layout = app_layout
server = app.server
# setup authentication
AUTH_DICT = {
os.getenv('DASH_AUTH_USERNAME'): os.getenv('DASH_AUTH_PASSWORD'),
}
BasicAuth(app, AUTH_DICT)
# connect to database
collection, db, client = connect()
# define callbacks
@app.callback(
Output('alert-element', 'is_open'),
Output('alert-element', 'children'),
Input('alert-message', 'data'),
)
def show_alert(
msg: str | None,
):
if msg is None or msg == '':
return False, ''
logging.info(f'updated alert message: {msg}')
return True, msg
@app.callback(
Output('success-element', 'is_open'),
Output('success-element', 'children'),
Input('success-message', 'data'),
)
def show_success(
msg: str | None,
):
if msg is None or msg == '':
return False, ''
logging.info(f"updated success message: {msg}")
return True, msg
@app.callback(
Output('progress-bar', 'value'),
Output('progress-bar', 'max'),
Output('progress-bar', 'label'),
Input('next-button', 'n_clicks'),
Input('upload-data', 'filename'),
)
def update_progress_bar(
n_clicks: int,
filename_list: list[str] | None,
):
logging.info('began updating progress bar')
global collection
# get values from database
num_total = count_documents(
collection=collection,
only_with_annotation=False,
)
num_handled = count_documents(
collection=collection,
only_with_annotation=True,
)
limit = int(num_total/20)
label_str = f"{num_handled}/{num_total}" if num_handled >= limit else ''
return num_handled, num_total, label_str
@app.callback(
Output('success-message', 'data', allow_duplicate=True),
Output('alert-message', 'data', allow_duplicate=True),
Input('upload-data', 'contents'),
State('upload-data', 'filename'),
prevent_initial_call=True,
)
def upload_images(
content_list: list[str] | None,
filename_list: list[str] | None,
):
logging.info('began uploading images')
global collection
# stop early if possible
if content_list is None or filename_list is None:
logging.warning('content_list is None -> nothing to upload')
return None, 'nothing selected to upload'.title()
# build list of visual communication
visual_communication_list = []
for content, filename in zip(content_list, filename_list):
try:
image = VisualCommunication.decode_image(content)
vis_com = VisualCommunication(
name=filename,
image=image,
)
except Exception as exc:
logging.warning(
(
'failed creating VisualCommunication object '
f"from file {filename}"
),
exc,
)
else:
visual_communication_list.append(vis_com)
# upsert documents
success = upsert_visual_communication(
collection=collection,
visual_communication_list=visual_communication_list,
)
if success:
logging.info(f"succesfully uploaded {len(filename_list)} images")
return 'successfully uploaded images'.title(), None
logging.warning('failed inserting images into database')
return None, 'failed uploading images'.title()
@app.callback(
Output('alert-message', 'data', allow_duplicate=True),
Output('vis-com-name', 'data'),
Output('image-container', 'src'),
Output({'type': 'annotation', 'index': ALL}, 'value'),
Input('next-button', 'n_clicks'),
State('vis-com-name', 'data'),
State('image-container', 'src'),
State({'type': 'annotation', 'index': ALL}, 'id'),
State({'type': 'annotation', 'index': ALL}, 'value'),
prevent_initial_call=True,
)
def cycle_visual_communication_data(
n_clicks: int,
vis_com_name: str,
image_src: str,
annotation_keys: list,
annotation_values: list,
):
logging.info('began cycling visual communication data')
global collection
# prepare default response
response = [
'',
vis_com_name,
image_src,
annotation_values,
]
# check if next-button clicked
if n_clicks == 0:
logging.info('stopping early: next-button has not yet been clicked')
return response
# check if visual communication name is set
if len(vis_com_name) > 0:
logging.info('saving annotations to database: %s', vis_com_name)
try:
# extract option keys
annotation_keys = [
elem['index']
for elem in annotation_keys
]
# ensure all options are set
logging.info(annotation_keys)
for option, value in zip(annotation_keys, annotation_values):
if value is None:
raise ValueError(f"{option} is not set")
# prepare data to save
annotation_keys = [
elem.replace(' ', '_')
for elem
in annotation_keys
]
annotation_values = [
elem.replace(' ', '_').lower()
for elem
in annotation_values
]
annotation_map = {
key: value
for key, value
in zip(annotation_keys, annotation_values)
}
# instantiate ModelOutputs object
annotations = ModelData.from_annotations(**annotation_map)
# save data to database
upsert_annotation(
collection=collection,
vis_com_name=vis_com_name,
annotations=annotations,
)
except (ValueError, ValidationError) as exc:
msg = f"failed saving annotation: {exc}"
logging.warning(msg)
response[0] = msg
return tuple(response)
# get new visual communication
logging.info('trying to get new visual communication')
try:
# get data
vis_com = get_visual_communication(
collection=collection,
with_annotation=False,
)
# set variables
vis_com_name = vis_com.name
image_src = vis_com.webencoded_image()
if vis_com.prediction is not None:
# TODO: update to use optional predictions
pass
else:
# reset annotations
annotation_values = [None for elem in annotation_values]
except NoDocumentFoundException:
msg = 'no unannotated data in database'
logging.warning(msg)
response[0] = msg
return tuple(response)
else:
response[1] = vis_com_name
response[2] = image_src
response[3] = annotation_values
logging.info('finished getting visual communication: %s', vis_com_name)
return tuple(response)
if __name__ == '__main__':
app.run(debug=True)
+3
View File
@@ -0,0 +1,3 @@
from __future__ import annotations
from .layout import app_layout
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
import dash_bootstrap_components as dbc
from dash import html
alerts_element = html.Div(
children=[
dbc.Alert(
children='',
id='alert-element',
dismissable=True,
fade=False,
is_open=False,
),
dbc.Alert(
children='',
id='success-element',
is_open=False,
duration=1000,
),
],
)
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
import dash_mantine_components as dmc
from .image import image_element
from .inputs import inputs_element
body_element = dmc.Container(
fluid=True,
children=[
dmc.Grid(
grow=True,
children=[
dmc.Col([image_element], span=5),
dmc.Col([inputs_element], span=7),
],
),
],
)
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
import dash_bootstrap_components as dbc
import dash_mantine_components as dmc
from dash import dcc
from dash import html
title_element = dmc.Center(
children=[
html.H2(
children=[
'Visual Critical Discourse Analysis Tool',
],
),
],
)
progress_bar_element = dbc.Progress(
id='progress-bar',
max=100,
value=0,
color='lime',
label='',
style={
'width': '400px',
},
)
upload_button_element = dcc.Upload(
children=[
dmc.Button(
'upload images'.title(),
id='upload-button',
color='lime',
n_clicks=0,
variant='outline',
radius='sm',
size='md',
),
],
multiple=True,
id='upload-data',
accept='image/png,image/jpeg,image/jpg',
)
header_element = dmc.Header(
height=80,
children=[
dmc.Group(
children=[
title_element,
progress_bar_element,
upload_button_element,
],
position='apart',
style={
'margin': '10px',
'padding': '10px',
},
),
],
)
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
from base64 import b64encode
from pathlib import Path
import dash_mantine_components as dmc
from dash import html
# read init img
init_img_path = Path(__file__).parent / 'init_img.png'
with open(init_img_path.absolute(), 'rb') as fh:
init_img_enc = b64encode(fh.read()).decode('utf-8')
# generate init img string
init_img_src = f"data:image/png;base64, {init_img_enc}"
image_element = dmc.Center(
html.Img(
style={
'width': '100%',
},
id='image-container',
src=init_img_src,
),
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
import dash_mantine_components as dmc
from .labels import labels_element
next_button = dmc.Button(
'next'.title(),
id='next-button',
n_clicks=0,
fullWidth=True,
color='lime',
radius='sm',
size='md',
style={
'height': '50px',
},
)
inputs_element = dmc.SimpleGrid(
children=[
labels_element,
next_button,
],
)
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
import dash_mantine_components as dmc
from dash import dcc
from dash import html
from core.dto import AngleData
from core.dto import ContactData
from core.dto import DistanceData
from core.dto import FramingData
from core.dto import InformationValueData
from core.dto import ModalityColorData
from core.dto import ModalityDepthData
from core.dto import ModalityLightingData
from core.dto import PointOfViewData
from core.dto import SalienceData
from core.dto import VisualSyntaxData
def generate_visual_syntax_options_map():
"""Generate map of titles and options for visual syntax labels."""
options_map = {}
# add experiential labels
options_map['visual syntax'] = VisualSyntaxData.list_fields()
return options_map
def generate_interpersonal_options_map():
"""Generate map of titles and options for interpersonal labels."""
options_map = {}
# add interpersonal labels
options_map['contact'] = ContactData.list_fields()
options_map['angle'] = AngleData.list_fields()
options_map['point of view'] = PointOfViewData.list_fields()
options_map['distance'] = DistanceData.list_fields()
options_map['modality lighting'] = ModalityLightingData.list_fields()
options_map['modality color'] = ModalityColorData.list_fields()
options_map['modality depth'] = ModalityDepthData.list_fields()
return options_map
def generate_textual_options_map():
"""Generate map of titles and options for textual labels."""
options_map = {}
# add textual labels
options_map['information value'] = InformationValueData.list_fields()
options_map['framing'] = FramingData.list_fields()
options_map['salience'] = SalienceData.list_fields()
return options_map
# prepare experiential container
experiential_map = generate_visual_syntax_options_map()
experiential_container = dmc.Col(
children=[
html.H4('experiential'.title()),
], span=5,
)
for title, options in experiential_map.items():
id_dict = {'type': 'annotation', 'index': title.replace('_', '-')}
experiential_container.children.append(
dmc.Container([
html.B(title.title()),
dcc.RadioItems(
options=[text.replace('_', ' ') for text in options],
id=id_dict,
),
]),
)
# prepare interpersonal container
interpersonal_map = generate_interpersonal_options_map()
interpersonal_container = dmc.Col(
children=[
html.H4('interpersonal'.title()),
], span=3,
)
for title, options in interpersonal_map.items():
id_dict = {'type': 'annotation', 'index': title.replace('_', '-')}
interpersonal_container.children.append(
dmc.Container([
html.B(title.title()),
dcc.RadioItems(
options=[text.replace('_', ' ') for text in options],
id=id_dict,
),
]),
)
# prepare textual container
textual_map = generate_textual_options_map()
textual_container = dmc.Col(
children=[
html.H4('textual'.title()),
], span=4,
)
for title, options in textual_map.items():
id_dict = {'type': 'annotation', 'index': title.replace('_', '-')}
textual_container.children.append(
dmc.Container([
html.B(title.title()),
dcc.RadioItems(
options=[text.replace('_', ' ') for text in options],
id=id_dict,
),
]),
)
labels_element = dmc.Grid(
children=[
experiential_container,
interpersonal_container,
textual_container,
],
)
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
import dash_mantine_components as dmc
from .alerts import alerts_element
from .body import body_element
from .header import header_element
from .stores import stores_element
app_layout = dmc.MantineProvider(
theme={
'fontFamily': '"Inter", sans-serif',
'components': {
'NavLink': {
'styles': {
'label': {
'color': '#c2c7d0',
},
},
},
},
},
children=[
stores_element,
alerts_element,
dmc.Container(
children=[
header_element,
body_element,
],
fluid=True,
),
],
)
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
from dash import dcc
from dash import html
stores_element = html.Div(
children=[
dcc.Store(id='alert-message', data=''),
dcc.Store(id='success-message', data=''),
dcc.Store(id='vis-com-name', data=''),
],
)
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
import logging
import os
from pathlib import Path
from app import app
from dotenv import load_dotenv
# prepare optional local setup
env_path = Path(__file__).parent.parent.parent / 'local.env'
assert env_path.exists()
load_dotenv(env_path)
# ensure env vars set
necesasary_var_list = {
'MONGO_HOST',
'MONGO_DB',
'MONGO_COLLECTION',
'MONGO_USER',
'MONGO_PASSWORD',
'DASH_AUTH_USERNAME',
'DASH_AUTH_PASSWORD',
}
for env_var in necesasary_var_list:
# ensure env var set
assert (
env_var in os.environ
), (
f"environment variable not set: {env_var}"
)
# setup logging stream handler
FMT = (
'%(asctime)s | '
'%(levelname)s | '
'%(filename)s | '
'%(funcName)s | '
'%(message)s'
)
DATEFMT = '%Y-%m-%d %H:%M:%S'
logging.basicConfig(format=FMT, datefmt=DATEFMT, level=logging.INFO)
logging.info('initialized app')
server = app.server
server.config.update(SECRET_KEY=os.urandom(24))
if __name__ == '__main__':
# prepare local env vars
os.environ['MONGO_HOST'] = 'localhost'
# run app
app.run(debug=True)
logging.info('started app')