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 0000000..7d821df Binary files /dev/null and b/web_ui/src/app/layout/init_img.png differ diff --git a/web_ui/src/app/layout/inputs.py b/web_ui/src/app/layout/inputs.py new file mode 100644 index 0000000..71b400c --- /dev/null +++ b/web_ui/src/app/layout/inputs.py @@ -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, + ], +) diff --git a/web_ui/src/app/layout/labels.py b/web_ui/src/app/layout/labels.py new file mode 100644 index 0000000..510b70f --- /dev/null +++ b/web_ui/src/app/layout/labels.py @@ -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, + ], +) diff --git a/web_ui/src/app/layout/layout.py b/web_ui/src/app/layout/layout.py new file mode 100644 index 0000000..0408ded --- /dev/null +++ b/web_ui/src/app/layout/layout.py @@ -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, + ), + + ], +) diff --git a/web_ui/src/app/layout/stores.py b/web_ui/src/app/layout/stores.py new file mode 100644 index 0000000..11c59da --- /dev/null +++ b/web_ui/src/app/layout/stores.py @@ -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=''), + ], +) diff --git a/web_ui/src/main.py b/web_ui/src/main.py new file mode 100644 index 0000000..71670ca --- /dev/null +++ b/web_ui/src/main.py @@ -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')