Compare commits

...
14 Commits
25 changed files with 473 additions and 310 deletions
Generated
+1 -35
View File
@@ -251,23 +251,6 @@ files = [
{file = "dash_table-5.0.0.tar.gz", hash = "sha256:18624d693d4c8ef2ddec99a6f167593437a7ea0bf153aa20f318c170c5bc7308"},
]
[[package]]
name = "discord-webhook"
version = "1.3.1"
description = "Easily send Discord webhooks with Python"
optional = false
python-versions = ">=3.10,<4.0"
files = [
{file = "discord_webhook-1.3.1-py3-none-any.whl", hash = "sha256:ede07028316de76d24eb811836e2b818b2017510da786777adcb0d5970e7af79"},
{file = "discord_webhook-1.3.1.tar.gz", hash = "sha256:ee3e0f3ea4f3dc8dc42be91f75b894a01624c6c13fea28e23ebcf9a6c9a304f7"},
]
[package.dependencies]
requests = ">=2.28.1,<3.0.0"
[package.extras]
async = ["httpx (>=0.23.0,<0.24.0)"]
[[package]]
name = "dnspython"
version = "2.6.1"
@@ -806,23 +789,6 @@ files = [
[package.extras]
cli = ["click (>=5.0)"]
[[package]]
name = "python-logging-discord-handler"
version = "0.1.4"
description = "Discord handler for Python logging framework"
optional = false
python-versions = ">=3.8,<4.0"
files = [
{file = "python_logging_discord_handler-0.1.4-py3-none-any.whl", hash = "sha256:b804b48e3f5af8c9c781a9afe8243c806f01521662e38a60fcda2c3631d27f4f"},
{file = "python_logging_discord_handler-0.1.4.tar.gz", hash = "sha256:8bfa839b6503b3b87e5851dd13bc5ff80bf2fadb496ac22c338ac10bd926f75a"},
]
[package.dependencies]
discord-webhook = ">=1.0.0,<2.0.0"
[package.extras]
docs = ["Sphinx (>=4.4.0,<5.0.0)", "sphinx-autodoc-typehints[docs] (>=1.16.0,<2.0.0)", "sphinx-rtd-theme (>=1.0.0,<2.0.0)", "sphinx-sitemap (>=2.2.0,<3.0.0)"]
[[package]]
name = "requests"
version = "2.31.0"
@@ -962,4 +928,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "e4aacea5a98281d935411e0d96152d1d24680f6c1e5288e9a1be913a0536b78e"
content-hash = "9146cd32f0af25ddc99e5950dbf6ebc5a249f16238834b9a5db8d1b45fa9efb9"
-1
View File
@@ -11,7 +11,6 @@ packages = [
[tool.poetry.dependencies]
python = "^3.12"
gunicorn = "^21.2.0"
python-logging-discord-handler = "^0.1.4"
python-dotenv = "^1.0.1"
dash = "^2.15.0"
dash-bootstrap-components = "^1.5.0"
+8 -2
View File
@@ -1,5 +1,11 @@
from .classes import (
ModelOutputs,
VisualCommunication
VisualCommunication,
NoDocumentFoundException
)
from .database import connect
from .utils import (
total_documents,
total_annotated,
get_visual_communication
)
from .database import connect
+14 -1
View File
@@ -3,6 +3,7 @@ from pydantic import BaseModel, field_validator, field_serializer
from PIL import Image
from io import BytesIO
from pathlib import Path
from base64 import b64encode
from src.model_experiential import ExperientialModelOutput
from src.model_interpersonal import (
@@ -36,7 +37,7 @@ class ModelOutputs(BaseModel):
class VisualCommunication(BaseModel):
name: str
image: Image.Image | BytesIO | bytes
image: Image.Image
annotation: ModelOutputs | None = None
prediction: ModelOutputs | None = None
@@ -73,3 +74,15 @@ class VisualCommunication(BaseModel):
def __repr__(self) -> str:
return f"{self.classname()}(name='{self.name}')"
def webencoded_image(self) -> str:
"""Convert image to be displayed on webpage."""
# convert images to bytes string
buffer = BytesIO()
self.image.save(buffer, format="png")
img_enc = b64encode(buffer.getvalue()).decode("utf-8")
return img_enc
class NoDocumentFoundException(Exception):
pass
+3
View File
@@ -1,7 +1,9 @@
from pymongo import MongoClient
from dotenv import load_dotenv
import logging
import os
def connect():
"""Connect to MongoDB."""
# load env vars
@@ -18,4 +20,5 @@ def connect():
db = client[os.getenv("MONGO_DB")]
collection = db[os.getenv("MONGO_COLLECTION")]
collection.create_index("name", unique=True)
logging.info("connected to database")
return collection, db, client
+48
View File
@@ -0,0 +1,48 @@
from pymongo.collection import Collection
from .classes import (
VisualCommunication,
NoDocumentFoundException
)
def total_documents(
collection: Collection
) -> int:
"""Get total number of documents in database."""
return collection.count_documents(filter={})
def total_annotated(
collection: Collection
) -> int:
"""Get total number of annotated documents in database."""
query = {
"annotation": { "$ne": None }
}
return collection.count_documents(filter=query)
def get_visual_communication(
collection: Collection,
with_annotation: bool = False,
with_prediction: bool = False
) -> VisualCommunication:
"""Get a random visual communication from the database."""
query = {}
if with_annotation:
query["annotation"] = {"$ne": None}
else:
query["annotation"] = None
if with_prediction:
query["prediction"] = {"$ne": None}
else:
query["prediction"] = None
data = collection.aggregate([
{ "$match": query }, # find using filters
{ "$sample": { "size": 1 } } # get one random
])
data = list(data) # read data from cursor object
if len(data) == 0:
raise NoDocumentFoundException
return VisualCommunication.model_validate(data[0])
-6
View File
@@ -1,6 +0,0 @@
[main]
logger_level=debug
[discord]
service_name=visual_critical_discourse_analysis
logger_level=warning
+33 -84
View File
@@ -1,99 +1,48 @@
import logging
from discord_logging.handler import DiscordHandler
from dotenv import load_dotenv
from configparser import ConfigParser
from pathlib import Path
import logging
import os
from src.web import server
# prepare optional local setup
env_path = Path(__file__).parent.parent / "local.env"
load_dotenv(env_path)
# load default values
config = ConfigParser()
config.read(Path(__file__).parent / 'defaults.ini')
LOGGER_LEVEL = config.get('main', 'logger_level')
DISCORD_SERVICE_NAME = config.get('discord', 'service_name')
DISCORD_LOGGER_LEVEL = config.get('discord', 'logger_level')
def setup_logging(
discord_webhook_url: str,
discord_service_name: str = DISCORD_SERVICE_NAME,
discord_logger_level: str = DISCORD_LOGGER_LEVEL,
logger_level: str = LOGGER_LEVEL,
) -> None:
# setup stream handler
fmt = (
'%(asctime)s | '
'%(levelname)s | '
'%(filename)s | '
'%(funcName)s | '
'%(message)s'
# ensure env vars set
necesasary_var_list = {
"MONGO_HOST",
"MONGO_DB",
"MONGO_COLLECTION",
"MONGO_USER",
"MONGO_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}"
)
datefmt = '%Y-%m-%d %H:%M:%S'
level = getattr(logging, logger_level.upper())
logging.basicConfig(format=fmt, datefmt=datefmt, level=level)
logger = logging.getLogger()
# add discord handler
discord_handler = DiscordHandler(
service_name=discord_service_name,
webhook_url=discord_webhook_url,
)
discord_handler.setFormatter(logging.Formatter('%(message)s'))
level = getattr(logging, discord_logger_level.upper())
discord_handler.setLevel(level=level)
logger.addHandler(discord_handler)
logging.debug('finished')
# 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)
def initialise_app() -> None:
"""
Ensure all necessary environment variables are provided
"""
# load env vars
load_dotenv()
# ensure env vars set
necesasary_var_map = {
'LOGGER_LEVEL': False,
'DISCORD_SERVICE_NAME': False,
'DISCORD_WEBHOOK_URL': True,
'DISCORD_LOGGER_LEVEL': False,
'DATABASE_HOST': True,
'DATABASE_PORT': True,
'DATABASE_ENGINE': True,
'DATABASE_DATABASE': True,
'DATABASE_USERNAME': True,
'DATABASE_PASSWORD': True,
}
for env_var, must_be_set in necesasary_var_map.items():
if must_be_set:
# ensure env var set
assert (
env_var in os.environ
), (
f"environment variable not set: {env_var}"
)
# set variable from env var
locals()[env_var.lower()] = os.getenv(key=env_var)
else:
# ensure default value set
assert env_var in globals(), f"default variable not set: {env_var}"
# set variable from env var with backup from default value
locals()[env_var.lower()] = os.getenv(
key=env_var,
default=globals()[env_var]
)
setup_logging(
discord_webhook_url=locals()['discord_webhook_url'],
discord_service_name=locals()['discord_service_name'],
discord_logger_level=locals()['discord_logger_level'],
logger_level=locals()['logger_level'],
)
logging.debug('finished')
logging.info("initialized app")
from src.web import app
server = app.server
if __name__ == "__main__":
from src.web import app
# initialise_app()
# prepare local env vars
os.environ["MONGO_HOST"] = "localhost"
# run app
app.run(debug=True)
logging.info("started app")
+58 -21
View File
@@ -1,28 +1,65 @@
from dash import Dash, html, Input, Output, State, page_container, page_registry
from dash import Dash, Input, Output
import dash_bootstrap_components as dbc
import dash_mantine_components as dmc
from .header import generate_header
from .body import generate_body
import logging
from .layout import app_layout
from src.database import (
connect,
get_visual_communication,
NoDocumentFoundException
)
# setup app
app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])
app.title = "visual critical discourse analysis".title()
app.layout = app_layout
server = app.server
app.layout = dmc.MantineProvider(
theme={
'fontFamily': '"Inter", sans-serif',
"components": {
"NavLink":{'styles':{'label':{'color':'#c2c7d0'}}}
},
},
children=[
dmc.Container(
[
generate_header(),
generate_body(),
], fluid=True
),
],
# 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:
return False, ""
return True, msg
@app.callback(
Output("alert-message", "data"),
Output("visual-communication-name", "data"),
Output("image-container", "src"),
Input("next-button", "n_clicks"),
prevent_initial_call=True
)
def load_unannotated_visual_communication_data(
n_clicks: int
):
global collection
try:
vis_com = get_visual_communication(
collection=collection,
with_annotation=False
)
except NoDocumentFoundException:
return (
"Did not find any unannotated data in database",
None,
""
)
# prepare return values
alert_message = None
vis_com_name = vis_com.name
img_src = f"data:image/png;base64, {vis_com.webencoded_image()}"
logging.info("updated visual communication")
return (
alert_message,
vis_com_name,
img_src
)
-143
View File
@@ -1,143 +0,0 @@
import dash_mantine_components as dmc
from dash import dcc, html
from typing import List
from src.model_experiential import ExperientialModelOutput
from src.model_interpersonal import (
ContactModelOutput,
AngleModelOutput,
PointOfViewModelOutput,
DistanceModelOutput,
ModalityLightingModelOutput,
ModalityColorModelOutput,
ModalityDepthModelOutput
)
from src.model_textual import (
InformationValueModelOutput,
FramingModelOutput,
SalienceModelOutput
)
def generate_option_labels(model) -> List[str]:
"""Generate presentable list of attributes from an OutputModel."""
labels = [
label.replace('_', ' ').title()
for label in model.list_fields()
]
return labels
def generate_experiential_options_map():
"""Generate map of titles and options for experiential labels."""
options_map = {}
# add experiential labels
options_map["experiential".title()] = generate_option_labels(ExperientialModelOutput)
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".title()] = generate_option_labels(ContactModelOutput)
options_map["angle".title()] = generate_option_labels(AngleModelOutput)
options_map["point of view".title()] = generate_option_labels(PointOfViewModelOutput)
options_map["distance".title()] = generate_option_labels(DistanceModelOutput)
options_map["modality lighting".title()] = generate_option_labels(ModalityLightingModelOutput)
options_map["modality color".title()] = generate_option_labels(ModalityColorModelOutput)
options_map["modality depth".title()] = generate_option_labels(ModalityDepthModelOutput)
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".title()] = generate_option_labels(InformationValueModelOutput)
options_map["framing".title()] = generate_option_labels(FramingModelOutput)
options_map["salience".title()] = generate_option_labels(SalienceModelOutput)
return options_map
def generate_body():
image_container = dmc.Image(
width=600,
height=600,
withPlaceholder=True,
placeholder=[dmc.Loader(color="gray", size="md")],
)
# prepare experiential container
experiential_map = generate_experiential_options_map()
experiential_container = dmc.Col(
children=[
dmc.Container([
html.H4(list(experiential_map.keys())[0]),
html.B("visual syntax".title()),
dcc.RadioItems(options=list(experiential_map.values())[0]),
])
], span=4
)
# prepare interpersonal container
interpersonal_map = generate_interpersonal_options_map()
interpersonal_container = dmc.Col(
children=[
html.H4("interpersonal".title()),
], span=4
)
for title, options in interpersonal_map.items():
interpersonal_container.children.append(
dmc.Container([
html.B(title),
dcc.RadioItems(options)
])
)
# 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():
textual_container.children.append(
dmc.Container([
html.B(title),
dcc.RadioItems(options)
])
)
# prepare labels container
label_container = dmc.Grid(
children=[
experiential_container,
interpersonal_container,
textual_container,
],
)
# build the full body container
body_container = dmc.Container(
dmc.Grid(
children=[
dmc.Col(
dmc.Center(
image_container,
),
span=5,
),
dmc.Col(
# radio buttons part
children = [
label_container,
dmc.Button(
"confirm",
id="submit-button",
fullWidth=True,
color="lime",
radius="sm",
size="md",
style={
"height": "50px"
}
),
], span=7,
),
# dmc.Col(span=1),
], grow=True
), fluid=True
)
return body_container
-15
View File
@@ -1,15 +0,0 @@
import dash_mantine_components as dmc
from dash import html
def generate_header():
header = dmc.Header(
height=80,
children=[
dmc.Container(
children=[
html.H2(children="Visual Critical Discourse Analysis Tool", style={"margin-left": "20px", "padding-top": "-5px"}),
], size="xl", px="xl",
),
]
)
return header
+1
View File
@@ -0,0 +1 @@
from .layout import app_layout
+15
View File
@@ -0,0 +1,15 @@
from dash import html, dcc
import dash_bootstrap_components as dbc
alerts_element = html.Div(
children=[
dbc.Alert(
children="",
id="alert-element",
dismissable=True,
fade=False,
is_open=False,
)
]
)
+20
View File
@@ -0,0 +1,20 @@
import dash_mantine_components as dmc
from dash import dcc, html
from typing import List
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)
],
)
],
)
+21
View File
@@ -0,0 +1,21 @@
import dash_mantine_components as dmc
from dash import html
header_element = dmc.Header(
height=80,
children=[
dmc.Container(
children=[
html.H2(
children="Visual Critical Discourse Analysis Tool",
style={
"margin-left": "20px",
"padding-top": "-5px"
},
),
],
size="xl", px="xl",
),
]
)
+19
View File
@@ -0,0 +1,19 @@
import dash_mantine_components as dmc
from dash import html
from pathlib import Path
from base64 import b64encode
# 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")
image_element = dmc.Center(
html.Img(
style={
"width": "100%",
},
id="image-container",
src=f"data:image/png;base64, {init_img_enc}"
)
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

+22
View File
@@ -0,0 +1,22 @@
import dash_mantine_components as dmc
from .labels import labels_element
next_button = dmc.Button(
"next".title(),
id="next-button",
fullWidth=True,
color="lime",
radius="sm",
size="md",
style={
"height": "50px"
}
)
inputs_element = dmc.SimpleGrid(
children=[
labels_element,
next_button
]
)
+104
View File
@@ -0,0 +1,104 @@
from dash import html, dcc
import dash_mantine_components as dmc
from typing import List
from src.model_experiential import ExperientialModelOutput
from src.model_interpersonal import (
ContactModelOutput,
AngleModelOutput,
PointOfViewModelOutput,
DistanceModelOutput,
ModalityLightingModelOutput,
ModalityColorModelOutput,
ModalityDepthModelOutput
)
from src.model_textual import (
InformationValueModelOutput,
FramingModelOutput,
SalienceModelOutput
)
def generate_option_labels(model) -> List[str]:
"""Generate presentable list of attributes from an OutputModel."""
labels = [
label.replace('_', ' ').title()
for label in model.list_fields()
]
return labels
def generate_experiential_options_map():
"""Generate map of titles and options for experiential labels."""
options_map = {}
# add experiential labels
options_map["experiential".title()] = generate_option_labels(ExperientialModelOutput)
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".title()] = generate_option_labels(ContactModelOutput)
options_map["angle".title()] = generate_option_labels(AngleModelOutput)
options_map["point of view".title()] = generate_option_labels(PointOfViewModelOutput)
options_map["distance".title()] = generate_option_labels(DistanceModelOutput)
options_map["modality lighting".title()] = generate_option_labels(ModalityLightingModelOutput)
options_map["modality color".title()] = generate_option_labels(ModalityColorModelOutput)
options_map["modality depth".title()] = generate_option_labels(ModalityDepthModelOutput)
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".title()] = generate_option_labels(InformationValueModelOutput)
options_map["framing".title()] = generate_option_labels(FramingModelOutput)
options_map["salience".title()] = generate_option_labels(SalienceModelOutput)
return options_map
# prepare experiential container
experiential_map = generate_experiential_options_map()
experiential_container = dmc.Col(
children=[
dmc.Container([
html.H4(list(experiential_map.keys())[0]),
html.B("visual syntax".title()),
dcc.RadioItems(options=list(experiential_map.values())[0]),
])
], span=5
)
# 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():
interpersonal_container.children.append(
dmc.Container([
html.B(title),
dcc.RadioItems(options)
])
)
# 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():
textual_container.children.append(
dmc.Container([
html.B(title),
dcc.RadioItems(options)
])
)
labels_element = dmc.Grid(
children=[
experiential_container,
interpersonal_container,
textual_container,
]
)
+35
View File
@@ -0,0 +1,35 @@
from dash import dcc
import dash_mantine_components as dmc
from .stores import stores_element
from .alerts import alerts_element
from .header import header_element
from .body import body_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
),
]
)
+9
View File
@@ -0,0 +1,9 @@
from dash import html, dcc
stores_element = html.Div(
children=[
dcc.Store(id="alert-message", storage_type="session"),
dcc.Store(id="visual-communication-name", storage_type="session"),
]
)
+21
View File
@@ -0,0 +1,21 @@
from pathlib import Path
from dotenv import load_dotenv
import os
from src.database import (
connect,
get_visual_communication
)
if __name__ == "__main__":
# prepare env vars
env_path = Path(__file__).parent.parent / "local.env"
assert env_path.exists()
load_dotenv(env_path)
os.environ["MONGO_HOST"] = "localhost"
# connect to database
collection, db, client = connect()
print(client.server_info())
# get visual communication
vis_com = get_visual_communication(collection)
print(vis_com.image)
+1 -2
View File
@@ -1,7 +1,5 @@
from pathlib import Path
from dotenv import load_dotenv
from pymongo import MongoClient
from typing import List
import os
from src.database import VisualCommunication, connect
@@ -25,3 +23,4 @@ if __name__ == "__main__":
print(repr(vis_com))
if data is not None:
print(vis_com.image)
print(vis_com.model_dump())
+20
View File
@@ -0,0 +1,20 @@
from pathlib import Path
from dotenv import load_dotenv
import os
from src.database import (
connect,
total_annotated
)
if __name__ == "__main__":
# prepare env vars
env_path = Path(__file__).parent.parent / "local.env"
assert env_path.exists()
load_dotenv(env_path)
os.environ["MONGO_HOST"] = "localhost"
# connect to database
collection, db, client = connect()
# get visual communication
num_docs = total_annotated(collection)
print(f"number of annotated documents in database: {num_docs}")
+20
View File
@@ -0,0 +1,20 @@
from pathlib import Path
from dotenv import load_dotenv
import os
from src.database import (
connect,
total_documents
)
if __name__ == "__main__":
# prepare env vars
env_path = Path(__file__).parent.parent / "local.env"
assert env_path.exists()
load_dotenv(env_path)
os.environ["MONGO_HOST"] = "localhost"
# connect to database
collection, db, client = connect()
# get visual communication
num_docs = total_documents(collection)
print(f"total number of documents in database: {num_docs}")