Skip to content

Commit edb0466

Browse files
author
Automated GitHub deployer
committed
Updated repository from deploy pipeline
1 parent 45f352b commit edb0466

148 files changed

Lines changed: 5386 additions & 1642 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
venv/
1+
*venv*
22
.idea
3+
.pdm-python
34

45
*.pyc
56
__pycache__/
@@ -19,3 +20,6 @@ tests/flask_session/*
1920

2021
scratch
2122
scratch/*
23+
24+
*.pot
25+
data

.pre-commit-config.yaml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
repos:
2+
- repo: local
3+
hooks:
4+
- id: ruff
5+
name: ruff
6+
entry: ruff check
7+
language: system
8+
pass_filenames: false
9+
always_run: true
10+
stages: [pre-push]
11+
- id: ruff-format
12+
name: ruff-format
13+
entry: ruff format --check
14+
language: system
15+
pass_filenames: false
16+
always_run: true
17+
stages: [pre-push]
18+
- id: pdm-lock-check
19+
name: pdm-lock-check
20+
description: run pdm lock --check to validate config
21+
entry: pdm lock --check
22+
language: system
23+
pass_filenames: false
24+
always_run: true
25+
files: ^pyproject.toml$
26+
stages: [pre-push]

README.md

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,34 +12,39 @@ The intended use looks as follows:
1212
4. The application **shows the search results** in descending order of similarity (according to the AI model)
1313
5. The user compares the snippets and the shown wrappers to **establish a match**
1414

15-
The application is developed and maintained by the Netherlands Forensic Institute (NFI).
16-
It is deployed on www.vuurwerkverkenner.nl.
15+
The application is developed and maintained by the Netherlands Forensic Institute (NFI) and is hosted at the following
16+
domains: www.vuurwerkverkenner.nl, www.vuurwerkverkenner.com, www.vuurwerkverkenner.ai,
17+
www.fireworksexplorer.eu, www.fireworksexplorer.com, www.fireworksexplorer.ai.
1718

1819
## Model and database
1920
The application relies on a **trained AI model** and a **background database** of fireworks, both maintained by the NFI.
2021
They are both stored on the HuggingFace organization page of the NFI (see [here](https://huggingface.co/NetherlandsForensicInstitute)).
2122
* The model, as well as a description of how it was trained, can be found [here](https://huggingface.co/NetherlandsForensicInstitute/vuurwerkverkenner).
2223
* The background database can be found [here](https://huggingface.co/datasets/NetherlandsForensicInstitute/vuurwerkverkenner-data).
2324

24-
If you want to run the application locally, download a copy of the model and database to your machine.
25-
26-
## Getting started
25+
## Running the app locally
2726
The following describes how to run the application on your own machine.
28-
It can be run with a **dummy** model and database (which are contained within this repository), or with a copy of the **real** model and database which are stored on HuggingFace.
27+
It can be run with the live model and database, which are stored on HuggingFace.
2928

3029
### Prerequisites
31-
* Python 3.8
30+
* Python 3.12
3231
* Any web browser
33-
* Optionally, a local copy of the model and data (see above)
32+
* An internet connection (required for downloading the data when running the application for the first time)
33+
34+
#### Recommended:
35+
* A Python virtual environment
36+
* A HuggingFace account + access token
37+
Set the token as an environment variable named `HF_TOKEN`
38+
NOTE: It is possible to download the data without this token, but you will likely have to restart the process
39+
a few times for it to fully complete te download.
3440

3541
### Requirements
36-
All package requirements for running the application are specified in `requirements.txt`.
37-
Packages can be installed from PyPi, e.g. through `pip install -r requirements.txt`.
42+
All package requirements for running the application are specified in `pyproject.toml`.
3843

39-
### Configuration
40-
Make sure parameters in `app/setup.cfg` (in particular `MODEL_CONFIG_FILE` and `META_DATA_DIR`) match the desired configuration.
41-
* For running the application with a **dummy** model and database, uncomment the indicated lines.
42-
* For running the application with the **real** model and database, make sure that the paths point to the correct locations on your machine. Additionally, make sure that the filepath for the weights file (in the model configuration file) matches the filepath on your machine.
44+
### Model and data
45+
The model and data are downloaded from HuggingFace when running the app for the first time. These data are saved
46+
to the paths `MODEL_DIR` and `META_DATA_DIR`,
47+
which are listed in `setup.cfg`.
4348

4449
### Running the application
4550
Simply run `flask run` inside a terminal.

app/app.py

Lines changed: 114 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,62 @@
22
import json
33
import logging
44
import os
5+
import re
6+
from collections.abc import Mapping
7+
from datetime import datetime
58
from pathlib import Path
69
from types import MappingProxyType
7-
from typing import Any, Iterable, Mapping, Optional, Union
10+
from typing import Any
811

9-
import confidence
1012
import numpy as np
11-
from flask import Flask, Response
13+
from flask import Flask, Response, request
14+
from flask_babel import Babel
15+
from huggingface_hub import snapshot_download
1216
from lru import LRU
17+
from speaklater import make_lazy_string
1318
from werkzeug.exceptions import HTTPException
1419

1520
from app.blueprints.help import help
1621
from app.blueprints.index import index
17-
from app.blueprints.login import login
1822
from app.blueprints.results import results
19-
from app.blueprints.utils import redirect_to
20-
from app.calculations.models import ClassificationModel, load_model
23+
from app.calculations.models import EmbeddingClassifier, ViTEmbeddingModel, ViTModelConfig
2124
from app.requests.validate import clean_text
25+
from app.utils import get_locale, redirect_to
26+
from config.render.meta_data_mapping import (
27+
META_DATA_KEY_MAPPING,
28+
META_DATA_VALUE_MAPPING_STRING,
29+
META_DATA_VALUE_MAPPING_WORDS,
30+
)
2231

2332
APP_DIR = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
2433

2534

26-
def get_model(app_config: Mapping[str, Any]) -> ClassificationModel:
27-
config = confidence.loadf(app_config['MODEL_CONFIG_FILE'])
28-
model = load_model(config.model)
29-
if not isinstance(model, ClassificationModel):
30-
raise ValueError('Classification model needed for prediction')
35+
def get_model(model_name: str, checkpoint_dir: str, meta_data: Mapping[str, Any]) -> EmbeddingClassifier:
36+
snapshot_download(repo_id=model_name, local_dir=checkpoint_dir)
37+
38+
with open(os.path.join(checkpoint_dir, 'settings.json')) as f:
39+
model_config = json.load(f)
40+
41+
if backbone := model_config.get("backbone"):
42+
if backbone == "vit":
43+
model_config["params"]["device"] = "cpu"
44+
vit_config = ViTModelConfig(**model_config["params"])
45+
embedding_model = ViTEmbeddingModel(config=vit_config)
46+
embedding_model.load(os.path.join(checkpoint_dir, "best_weights.pt"), device="cpu")
47+
else:
48+
raise ValueError(f"backbone '{backbone}' not supported")
49+
else:
50+
raise ValueError("'backbone' field missing in `settings.json`")
51+
52+
model = EmbeddingClassifier(model=embedding_model)
53+
model.update_embeddings(meta_data)
54+
3155
return model
3256

3357

34-
def build_image_url(label: str, wrapper_index: int, filename: str) -> str:
35-
return f'fireworks_{label}/wrappers/{wrapper_index}/{filename}'
58+
def build_image_url(category: str, label: str, filename: str) -> str:
59+
"""Build the url to the image."""
60+
return f'{category}/{label}/{filename}'
3661

3762

3863
def parse_json(filename: str) -> Mapping[str, Any]:
@@ -44,49 +69,46 @@ def parse_json(filename: str) -> Mapping[str, Any]:
4469
return json.loads(f.read())
4570

4671

47-
def parse_meta_data(meta_data_dir: str, key_order: Optional[Iterable] = None) -> Mapping[str, Any]:
72+
def get_meta_data(app_config: Mapping[str, Any]) -> Mapping[str, Any]:
4873
"""
49-
Parse and return the fireworks meta-data from the meta-data directory.
50-
51-
:param meta_data_dir: the directory to parse the meta-data from.
52-
It is assumed that the gzip-compressed JSON containing all the meta-data is
53-
located in the root of this directory and has the filename "meta.json.gz".
54-
It is also assumed that the JSON file follows the same structure as in the
55-
example JSON that can be found in "data/demo_data/meta.json.gz"
56-
:param key_order: optional parameter for defining the insertion order of the
57-
meta-data keys. This defines the order in which the meta-data fields will be
58-
displayed on the page
74+
Download, parse and return the fireworks meta-data from the meta-data directory. The meta-data should consist of
75+
a `meta.json.gz` file and nested folders each category/label containing the images.
5976
77+
:param app_config: Global constants used in the application
6078
:returns: an immutable mapping containing all the parsed meta-data
6179
"""
62-
filename = os.path.join(meta_data_dir, 'meta.json.gz')
63-
meta_data = parse_json(filename=filename)
64-
for label, group in meta_data.items():
65-
for i, item in group["wrappers"].items():
66-
# insert the keys in order of `key_order` if defined
67-
if key_order:
68-
item_sorted = {key: item[key] for key in key_order if key in item}
69-
item_sorted.update({key: item[key] for key in item if key not in item_sorted})
70-
item = item_sorted
80+
if app_config.get('META_DATA_HF'):
81+
snapshot_download(
82+
repo_id=app_config['META_DATA_HF'], repo_type="dataset", local_dir=app_config['META_DATA_DIR']
83+
)
84+
meta_data = parse_json(filename=os.path.join(app_config['REFERENCE_DATA_DIR'], 'meta.json.gz'))
85+
for category, articles in meta_data.items():
86+
for label, article in articles.items():
87+
article_meta_data = article.get('wrappers', {})
88+
# insert the keys in order of `META_DATA_KEY_MAPPING.keys()`
89+
article_meta_data_sorted = {
90+
key: article_meta_data[key] for key in META_DATA_KEY_MAPPING.keys() if key in article_meta_data
91+
}
92+
article_meta_data_sorted.update(
93+
{key: article_meta_data[key] for key in article_meta_data if key not in article_meta_data_sorted}
94+
)
95+
article_meta_data = article_meta_data_sorted # noqa
7196
# clean the wrapper text
72-
item["text"] = clean_text(item["text"])
97+
article_meta_data["text"] = clean_text(article_meta_data.get("text", ""))
7398
# retrieve the image location for the wrapper
74-
item["image"] = build_image_url(label, i, 'wrapper.jpg')
75-
images = os.listdir(os.path.join(meta_data_dir, f'fireworks_{label}', 'wrappers', str(i)))
76-
item["meta_images"] = tuple(build_image_url(label, i, img) for img in images if img != 'wrapper.jpg')
99+
article_meta_data["image"] = build_image_url(category, label, app_config['WRAPPER_FILENAME'])
100+
images = os.listdir(os.path.join(app_config['REFERENCE_DATA_DIR'], category, label))
101+
article_meta_data["meta_images"] = tuple(
102+
build_image_url(category, label, img) for img in images if img != app_config['WRAPPER_FILENAME']
103+
)
77104
# overwrite entry
78-
group["wrappers"][i] = item
79-
# convert the embeddings to numpy arrays
80-
group["embeddings"] = np.asarray(group["embeddings"])
105+
article["wrappers"] = article_meta_data
106+
# convert the embeddings to numpy arrays
107+
article["embeddings"] = np.asarray(article["embeddings"])
81108
return MappingProxyType(meta_data)
82109

83110

84-
def parse_mapping(filename: str) -> Mapping[str, str]:
85-
filename = os.path.join(APP_DIR, 'config', 'render', filename)
86-
return MappingProxyType(parse_json(filename=filename))
87-
88-
89-
def create_app(config: Union[str, Mapping[str, Any]] = 'setup.cfg'):
111+
def create_app(config: str | Mapping[str, Any] = 'setup.cfg'):
90112
"""
91113
Create the app. This method is automatically detected and triggered by Flask.
92114
@@ -100,46 +122,65 @@ def create_app(config: Union[str, Mapping[str, Any]] = 'setup.cfg'):
100122
else:
101123
app.config.update(config)
102124

125+
Babel(app, locale_selector=get_locale)
126+
103127
app.register_blueprint(index.index_page)
104128
app.register_blueprint(help.help_page)
105129
app.register_blueprint(results.results_page)
106130

107-
app.meta_data_key_mapping = parse_mapping('meta_data_key_mapping.json')
108-
app.endangerment_mapping = parse_mapping('endangerment_mapping.json')
131+
if not os.path.isabs(app.config['META_DATA_DIR']):
132+
app.config['META_DATA_DIR'] = str(Path().absolute() / app.config['META_DATA_DIR'])
133+
app.config['REFERENCE_DATA_DIR'] = os.path.join(app.config['META_DATA_DIR'], 'reference_data')
134+
app.meta_data = get_meta_data(app_config=app.config)
109135

110-
if meta_data_dir := app.config.get("META_DATA_DIR", None):
111-
# change relative path to absolute path if necessary
112-
if not os.path.isabs(meta_data_dir):
113-
app.config['META_DATA_DIR'] = str(Path().absolute() / Path(meta_data_dir))
114-
app.meta_data = parse_meta_data(meta_data_dir=app.config['META_DATA_DIR'],
115-
key_order=app.meta_data_key_mapping.keys())
116-
else:
117-
app.meta_data = {}
118-
119-
app.model = get_model(app.config)
136+
if not os.path.isabs(app.config['MODEL_DIR']):
137+
app.config['MODEL_DIR'] = str(Path().absolute() / app.config['MODEL_DIR'])
138+
app.config["MODEL_DIR"] = str(Path().absolute() / Path(app.config['MODEL_DIR']))
139+
app.model = get_model(app.config.get("MODEL_HF"), app.config["MODEL_DIR"], app.meta_data)
120140
app.cache = LRU(app.config.get('CACHE_SIZE'))
121141
app.jinja_env.filters['zip'] = zip
142+
app.jinja_env.filters['translate_meta_data_values'] = translate_meta_data_values
122143

123144
register_error_handlers(app)
124145

125146
app.after_request(after_request)
126147

127-
if app.config.get("LOGIN_REQUIRED", None):
128-
app.register_blueprint(login.login_page)
129-
from flask_session import Session
130-
Session(app)
131-
132148
return app
133149

134150

151+
def translate_meta_data_values(input: str):
152+
"""
153+
Translate the parts of the string defined in `META_DATA_VALUE_MAPPING_STRING` and then split the string in words and
154+
translate individually from `META_DATA_VALUE_MAPPING_WORDS`. Return the translated string as a LazyString.
155+
"""
156+
# Make the mapping keys lowercase
157+
smap = {k.lower(): v for k, v in META_DATA_VALUE_MAPPING_STRING.items()}
158+
wmap = {k.lower(): v for k, v in META_DATA_VALUE_MAPPING_WORDS.items()}
159+
160+
# Compile the patterns to replace
161+
sub_pat = re.compile("|".join(map(re.escape, sorted(smap, key=len, reverse=True))), re.IGNORECASE)
162+
word_pat = re.compile(rf"(?<![A-Za-z0-9])({'|'.join(map(re.escape, wmap))})(?![A-Za-z0-9])", re.IGNORECASE)
163+
164+
def apply_subs(string: str) -> str:
165+
string = sub_pat.sub(lambda m: str(smap[m.group(0).lower()]), string)
166+
string = word_pat.sub(lambda m: str(wmap[m.group(0).lower()]), string)
167+
return string
168+
169+
# Apply the substitutions and return as a lazy string
170+
return make_lazy_string(lambda t: apply_subs(t).capitalize(), input)
171+
172+
135173
def register_error_handlers(app):
136174
app.register_error_handler(404, redirect_bad_request)
137175
app.register_error_handler(405, redirect_bad_request)
138176
app.register_error_handler(Exception, handle_exception)
139177

140178

141179
def redirect_bad_request(ex: Exception):
142-
logging.error(f"Bad request received (will be ignored with a redirect to the index page): {ex}")
180+
logging.error(
181+
f"{datetime.now()} Bad request received: {request.path} "
182+
f"(will be ignored with a redirect to the index page): {ex}"
183+
)
143184
return redirect_to("index")
144185

145186

@@ -155,13 +196,15 @@ def handle_exception(ex: Exception):
155196

156197
def after_request(response: Response):
157198
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
158-
response.headers['Content-Security-Policy'] = "default-src 'none';" \
159-
"img-src 'self' data:;" \
160-
"font-src 'self';" \
161-
"connect-src 'self';" \
162-
"script-src 'self' 'unsafe-inline';" \
163-
"style-src 'self' 'unsafe-inline';" \
164-
"form-action 'self'; "
199+
response.headers['Content-Security-Policy'] = (
200+
"default-src 'none';"
201+
"img-src 'self' data:;"
202+
"font-src 'self';"
203+
"connect-src 'self';"
204+
"script-src 'self' 'unsafe-inline';"
205+
"style-src 'self' 'unsafe-inline';"
206+
"form-action 'self'; "
207+
)
165208
response.headers['X-Content-Type-Options'] = 'nosniff'
166209
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
167210
return response

app/blueprints/help/help.py

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,17 @@
1-
import json
21
import os
3-
from functools import lru_cache
42

5-
from flask import Blueprint, render_template
3+
from flask import Blueprint, current_app, render_template
64

7-
from app.blueprints.login import verify_authorization
5+
from app.blueprints.help.resources.help import get_faq
86

97
RESOURCES_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'resources')
108
help_page = Blueprint('help', __name__, template_folder='templates')
119

1210

13-
def _parse_json_data(filename: str):
14-
with open(filename, 'r') as f:
15-
return json.loads(f.read())
16-
17-
18-
@lru_cache(maxsize=None)
1911
def _render_help_page():
20-
json_data = _parse_json_data(filename=os.path.join(RESOURCES_DIR, 'help.json'))
21-
return render_template('help.html', json_data=json_data)
12+
return render_template('help.html', json_data=get_faq(current_app))
2213

2314

2415
@help_page.route('/help')
25-
@verify_authorization
2616
def show():
2717
return _render_help_page()

0 commit comments

Comments
 (0)