From bc653a0be4d9b4214e69b97da35aca555b6a049e Mon Sep 17 00:00:00 2001 From: Brian Bjarke Jensen Date: Tue, 7 May 2024 20:19:18 +0200 Subject: [PATCH] added new folder and ran tests --- docker-compose.local.yml | 33 ++-- docker-compose.server.yml | 3 +- web_ui/Dockerfile | 57 +++++++ web_ui/src/app/__init__.py | 3 + web_ui/src/app/app.py | 251 +++++++++++++++++++++++++++++ web_ui/src/app/layout/__init__.py | 3 + web_ui/src/app/layout/alerts.py | 23 +++ web_ui/src/app/layout/body.py | 20 +++ web_ui/src/app/layout/header.py | 62 +++++++ web_ui/src/app/layout/image.py | 24 +++ web_ui/src/app/layout/init_img.png | Bin 0 -> 9479 bytes web_ui/src/app/layout/inputs.py | 25 +++ web_ui/src/app/layout/labels.py | 113 +++++++++++++ web_ui/src/app/layout/layout.py | 36 +++++ web_ui/src/app/layout/stores.py | 13 ++ web_ui/src/main.py | 53 ++++++ 16 files changed, 700 insertions(+), 19 deletions(-) create mode 100644 web_ui/Dockerfile create mode 100644 web_ui/src/app/__init__.py create mode 100644 web_ui/src/app/app.py create mode 100644 web_ui/src/app/layout/__init__.py create mode 100644 web_ui/src/app/layout/alerts.py create mode 100644 web_ui/src/app/layout/body.py create mode 100644 web_ui/src/app/layout/header.py create mode 100644 web_ui/src/app/layout/image.py create mode 100644 web_ui/src/app/layout/init_img.png create mode 100644 web_ui/src/app/layout/inputs.py create mode 100644 web_ui/src/app/layout/labels.py create mode 100644 web_ui/src/app/layout/layout.py create mode 100644 web_ui/src/app/layout/stores.py create mode 100644 web_ui/src/main.py diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 011ee49..a190a93 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -1,21 +1,20 @@ -version: '3.7' services: - # app: - # image: visual_critical_discourse_analysis:dev - # container_name: visual_critical_discourse_analysis - # build: - # context: . - # dockerfile: Dockerfile - # env_file: - # - local.env - # environment: - # - ENV=DEV - # ports: - # - 8050:8050 - # networks: - # - backend - # depends_on: - # - mongo + app: + image: visual_critical_discourse_analysis:dev + container_name: visual_critical_discourse_analysis + build: + context: . + dockerfile: ./web_ui/Dockerfile + env_file: + - local.env + environment: + - ENV=DEV + ports: + - 8050:8050 + networks: + - backend + depends_on: + - mongo mongo: image: mongo:latest container_name: mongo diff --git a/docker-compose.server.yml b/docker-compose.server.yml index 73a4d10..2e8007f 100644 --- a/docker-compose.server.yml +++ b/docker-compose.server.yml @@ -1,11 +1,10 @@ -version: '3.7' services: app: image: visual_critical_discourse_analysis:dev container_name: visual_critical_discourse_analysis build: context: . - dockerfile: Dockerfile + dockerfile: ./web_ui/Dockerfile env_file: - server.env ports: diff --git a/web_ui/Dockerfile b/web_ui/Dockerfile new file mode 100644 index 0000000..59b4f9e --- /dev/null +++ b/web_ui/Dockerfile @@ -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" ] diff --git a/web_ui/src/app/__init__.py b/web_ui/src/app/__init__.py new file mode 100644 index 0000000..238bf79 --- /dev/null +++ b/web_ui/src/app/__init__.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +from .app import app diff --git a/web_ui/src/app/app.py b/web_ui/src/app/app.py new file mode 100644 index 0000000..c1d5083 --- /dev/null +++ b/web_ui/src/app/app.py @@ -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) diff --git a/web_ui/src/app/layout/__init__.py b/web_ui/src/app/layout/__init__.py new file mode 100644 index 0000000..a74fbeb --- /dev/null +++ b/web_ui/src/app/layout/__init__.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +from .layout import app_layout diff --git a/web_ui/src/app/layout/alerts.py b/web_ui/src/app/layout/alerts.py new file mode 100644 index 0000000..8be4a69 --- /dev/null +++ b/web_ui/src/app/layout/alerts.py @@ -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, + ), + ], +) diff --git a/web_ui/src/app/layout/body.py b/web_ui/src/app/layout/body.py new file mode 100644 index 0000000..374483b --- /dev/null +++ b/web_ui/src/app/layout/body.py @@ -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), + ], + ), + ], +) diff --git a/web_ui/src/app/layout/header.py b/web_ui/src/app/layout/header.py new file mode 100644 index 0000000..a4436f5 --- /dev/null +++ b/web_ui/src/app/layout/header.py @@ -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', + }, + ), + ], +) diff --git a/web_ui/src/app/layout/image.py b/web_ui/src/app/layout/image.py new file mode 100644 index 0000000..aaada8d --- /dev/null +++ b/web_ui/src/app/layout/image.py @@ -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, + ), +) diff --git a/web_ui/src/app/layout/init_img.png b/web_ui/src/app/layout/init_img.png new file mode 100644 index 0000000000000000000000000000000000000000..7d821dfcd976138a85122865a919288bf9b54707 GIT binary patch literal 9479 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