From 1d854c8c70cf3b9864f8f8a4e2e5e55865a0777d Mon Sep 17 00:00:00 2001 From: Jonathan LEGRAND Date: Thu, 13 Jun 2024 10:18:28 +0200 Subject: [PATCH] Improving the WebUI --- src/plantimager/webui/app.py | 6 +- src/plantimager/webui/pages/config.py | 204 +++++++++++++++++++++ src/plantimager/webui/pages/plantdb_api.py | 129 ------------- src/plantimager/webui/pages/scan.py | 122 ++++++++++-- 4 files changed, 312 insertions(+), 149 deletions(-) create mode 100644 src/plantimager/webui/pages/config.py delete mode 100644 src/plantimager/webui/pages/plantdb_api.py diff --git a/src/plantimager/webui/app.py b/src/plantimager/webui/app.py index efcc600..b0f9dbb 100644 --- a/src/plantimager/webui/app.py +++ b/src/plantimager/webui/app.py @@ -33,10 +33,6 @@ def parsing(): app_args.add_argument('--port', type=int, default=REST_API_PORT, help="port used to serve the application") - hw_args = parser.add_argument_group("Hardware options") - hw_args.add_argument('--cnc_dev', type=str, default="/dev/ttyACM0") - hw_args.add_argument('--gimbal_dev', type=str, default="/dev/ttyACM1") - return parser @@ -66,7 +62,7 @@ def main(url, port): href="/scan")), dbc.NavItem( dbc.NavLink("PlantDB", style={'color': "#f3f3f3"}, - href="/plantdb_api")), + href="/config")), dbc.NavItem( dbc.NavLink("Tutorial", style={'color': "#f3f3f3"}, href="https://docs.romi-project.eu/plant_imager/tutorials/reconstruct_scan/")), diff --git a/src/plantimager/webui/pages/config.py b/src/plantimager/webui/pages/config.py new file mode 100644 index 0000000..1c283a3 --- /dev/null +++ b/src/plantimager/webui/pages/config.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import dash +import dash_bootstrap_components as dbc +from dash import Input +from dash import Output +from dash import State +from dash import callback +from dash import dcc +from dash import html + +from plantdb.rest_api_client import REST_API_PORT +from plantdb.rest_api_client import REST_API_URL +from plantdb.rest_api_client import test_db_availability +from plantimager.webui.utils import get_dataset_dict + +global dataset_dict + +dash.register_page(__name__, path="/config") + +# ----------------------------------------------------------------------------- +# Forms and callbacks to connect to the PlantDB REST API. +# ----------------------------------------------------------------------------- +rest_api_card = dbc.Card( + id="rest-api-card", + children=[ + dbc.CardHeader("PlantDB REST API"), + dbc.CardBody([ + dbc.Row([ + # - Input form to specify REST API URL: + dbc.Col([ + dbc.Label("REST API URL:"), + dbc.Input(id="ip-address", type="url", value=REST_API_URL), + dbc.FormText(f"Use '{REST_API_URL}' for a local database.", color="secondary"), + ], width=8, ), + # - Input form to specify REST API port: + dbc.Col([ + dbc.Label("REST API port:"), + dbc.Input(id="ip-port", type="text", value=REST_API_PORT), + dbc.FormText(f"Should be '{REST_API_PORT}' by default.", color="secondary"), + ], width=4, ), + ]), + ]), + dbc.CardFooter([ + dbc.Row([ + # - Test connexion to REST API button + dbc.Col([ + dbc.Button("Test connexion", id="connect-button", color="primary"), + dbc.FormText( + dbc.Alert([ + html.I(className="bi bi-info-circle-fill me-2"), + "Unknown server availability." + ], color="info"), + id="connexion-status"), + ], align="center", ), + # - Load scans from REST API button + dbc.Col([ + dbc.Button("Load datasets", id="load-button", color="primary", disabled=True), + dbc.FormText( + dbc.Alert([ + html.I(className="bi bi-info-circle-fill me-2"), + "Undefined list of datasets." + ], color="info"), + id="load-status"), + ], align="center", ) + ]) + ], style={"align-content": 'center'}) + + ] +) + +scanner_card = [ + dbc.Card( + id="scanner-card", + children=[ + dbc.CardHeader("Scanner configuration"), + dbc.CardBody([ + dbc.Row([ + # - Input form to specify CNC serial port: + dbc.Col([ + dbc.Label("CNC serial port:"), + dbc.Input(id="cnc-port-input", type="text", value="/dev/ttyACM0", + placeholder='Enter a serial port...'), + dbc.FormText("Should be something like `/dev/ttyACM0`", color="secondary"), + ], width=8, ), + # - Input form to specify Gimbal serial port: + dbc.Col([ + dbc.Label("Gimbal serial port:"), + dbc.Input(id="gimbal-port-input", type="text", value="/dev/ttyACM1", + placeholder='Enter a serial port...'), + dbc.FormText("Should be something like `/dev/ttyACM1`", color="secondary"), + ], width=8, ), + # - Input form to specify Camera URL: + dbc.Col([ + dbc.Label("Camera URL:"), + dbc.Input(id="camera-url-input", type="url", value="192.168.122.1:10000", + placeholder='Enter a valid URL...'), + dbc.FormText("Should be something like `192.168.122.1:10000`", color="secondary"), + ], width=8, ), + ]) + ]), + dbc.CardFooter([dbc.Row([ + dbc.Col([ + dbc.Button("Export settings", id="hw-settings-button") + ]), + dbc.Col([ + dbc.Button("Initialize scanner", id="hw-init-button") + ]) + ]) + ]) + ] + ) +] + +layout = html.Div([ + dcc.Store(id='cnc-port', data=None), + dcc.Store(id='gimbal-port', data=None), + dcc.Store(id='camera-url', data=None), + dcc.Store(id='scanner-obj', data=None), + # Content of the scan page: + dbc.Button("< Back", href="/", style={'width': '200px'}), + html.Br(), + dbc.Row( + id="conf-page-content", + children=[ + dbc.Col(rest_api_card, md=6), + dbc.Col(scanner_card, md=6), + ] + ) +]) + + +@callback(Output('connexion-status', 'children'), + Output('load-button', 'disabled'), + Output('rest-api-host', 'data'), + Output('rest-api-port', 'data'), + Input('connect-button', 'n_clicks'), + State('ip-address', 'value'), + State('ip-port', 'value')) +def test_connect(n_clicks, host, port): + if test_db_availability(host, int(port)): + res = dbc.Alert([ + html.I(className="bi bi-check-circle-fill me-2"), "Server available.", + ], color="success", ) + else: + res = dbc.Alert([ + html.I(className="bi bi-x-octagon-fill me-2"), f"Server {host}:{port} unavailable!", + ], color="danger", ) + return res, not res, host, port + + +@callback(Output('load-status', 'children'), + Output('dataset-dict', 'data'), + Input('load-button', 'n_clicks'), + State('ip-address', 'value'), + State('ip-port', 'value')) +def update_db(n_clicks, host, port): + dataset_dict = get_dataset_dict(host, port) + if len(dataset_dict) > 0: + res = dbc.Alert([ + html.I(className="bi bi-check-circle-fill me-2"), f"Loaded {len(dataset_dict)} dataset.", + ], color="success", ) + else: + res = dbc.Alert([ + html.I(className="bi bi-x-octagon-fill me-2"), f"Could not load any dataset!", + ], color="danger", ) + return res, dataset_dict + + +@callback(Output('cnc-port', 'data'), + Output('gimbal-port', 'data'), + Output('camera-url', 'data'), + Input('hw-settings-button', 'n_clicks'), + State('cnc-port-input', 'value'), + State('gimbal-port-input', 'value'), + State('camera-url-input', 'value'), + prevent_initial_call=True) +def set_hw_config(cnc_port, gimbal_port, camera_url): + return cnc_port, gimbal_port, camera_url + + +@callback(Output('scanner-obj', 'data'), + Input('hw-init-button', 'n_clicks'), + State('cnc-port', 'data'), + State('gimbal-port', 'data'), + State('camera-url', 'data'), + prevent_initial_call=True) +def init_scanner(n_clicks, cnc_port, gimbal_port, camera_url): + from plantimager.scanner import Scanner + from plantimager.dummy import CNC + from plantimager.dummy import Gimbal + from plantimager.dummy import Camera + cnc = CNC() + gimbal = Gimbal() + camera = Camera() + return Scanner(cnc, gimbal, camera) + # from plantimager.grbl import CNC + # from plantimager.blgimbal import Gimbal + # from plantimager.urlcam import Camera + # cnc = CNC(cnc_port, x_lims=[0, 800], y_lims=[0, 800], z_lims=[0, 100]) + # gimbal = Gimbal(gimbal_port, has_tilt=False, invert_rotation=True) + # camera = Camera(camera_url) + # return Scanner(cnc, gimbal, camera) diff --git a/src/plantimager/webui/pages/plantdb_api.py b/src/plantimager/webui/pages/plantdb_api.py deleted file mode 100644 index be0128c..0000000 --- a/src/plantimager/webui/pages/plantdb_api.py +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import dash -import dash_bootstrap_components as dbc -from dash import Input -from dash import Output -from dash import State -from dash import callback -from dash import html - -from plantimager.webui.utils import get_dataset_dict -from plantdb.rest_api_client import REST_API_PORT -from plantdb.rest_api_client import REST_API_URL -from plantdb.rest_api_client import test_db_availability - -global dataset_dict - -dash.register_page(__name__, path="/plantdb_api") - -# ----------------------------------------------------------------------------- -# Forms and callbacks to connect to the PlantDB REST API. -# ----------------------------------------------------------------------------- -layout = dbc.Row( - children=[ - dbc.Col( - dbc.Button("< Back", href="/"), - ), - html.Header("Configure the connexion to a PlantDB REST API."), - # - Input form to specify REST API URL: - dbc.Col( - children=[ - dbc.Label("REST API URL:"), - dbc.Input(id="ip-address", type="text", value=REST_API_URL), - dbc.FormText(f"Use '{REST_API_URL}' for a local database."), - ], - width=6, - ), - # - Input form to specify REST API port: - dbc.Col( - children=[ - dbc.Label("REST API port:"), - dbc.Input(id="ip-port", type="text", value=REST_API_PORT), - dbc.FormText(f"Should be '{REST_API_PORT}' by default."), - ], - width=2, - ), - # - Test connexion to REST API button - dbc.Col( - children=[ - dbc.Button("Test connexion", id="connect-button", color="primary"), - dbc.FormText( - dbc.Alert([ - html.I(className="bi bi-info-circle-fill me-2"), - "Unknown server availability." - ], color="info"), - id="connexion-status"), - ], - width=2, align="center" - ), - # - Load scans from REST API button - dbc.Col( - children=[ - dbc.Button("Load datasets", id="load-button", color="primary", disabled=True), - dbc.FormText( - dbc.Alert([ - html.I(className="bi bi-info-circle-fill me-2"), - "Undefined list of datasets." - ], color="info"), - id="load-status"), - ], - width=2, align="center" - ) - ], - justify="center" -) - - -@callback(Output('connexion-status', 'children'), - Output('load-button', 'disabled'), - Output('rest-api-host', 'data'), - Output('rest-api-port', 'data'), - Input('connect-button', 'n_clicks'), - State('ip-address', 'value'), - State('ip-port', 'value')) -def test_connect(n_clicks, host, port): - if test_db_availability(host, int(port)): - res = dbc.Alert( - [ - html.I(className="bi bi-check-circle-fill me-2"), - "Server available.", - ], - color="success", - ) - else: - res = dbc.Alert( - [ - html.I(className="bi bi-x-octagon-fill me-2"), - f"Server {host}:{port} unavailable!", - ], - color="danger", - ) - return res, not res, host, port - - -@callback(Output('load-status', 'children'), - Output('dataset-dict', 'data'), - Input('load-button', 'n_clicks'), - State('ip-address', 'value'), - State('ip-port', 'value')) -def update_db(n_clicks, host, port): - dataset_dict = get_dataset_dict(host, port) - if len(dataset_dict) > 0: - res = dbc.Alert( - [ - html.I(className="bi bi-check-circle-fill me-2"), - f"Loaded {len(dataset_dict)} dataset.", - ], - color="success", - ) - else: - res = dbc.Alert( - [ - html.I(className="bi bi-x-octagon-fill me-2"), - f"Could not load any dataset!", - ], - color="danger", - ) - return res, dataset_dict diff --git a/src/plantimager/webui/pages/scan.py b/src/plantimager/webui/pages/scan.py index 8ce5be8..eebd232 100644 --- a/src/plantimager/webui/pages/scan.py +++ b/src/plantimager/webui/pages/scan.py @@ -1,9 +1,13 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +import os from base64 import b64decode +from io import BytesIO from logging import getLogger +from zipfile import ZipFile import dash_bootstrap_components as dbc +import requests import toml from dash import Input from dash import Output @@ -14,8 +18,10 @@ from dash import html from dash import register_page +from plantimager.webui.utils import base_url from plantimager.webui.utils import config_upload from plantimager.webui.utils import create_temp_fsdb +from plantimager.webui.utils import temp_scan_dir from romitask.log import get_log_filename register_page(__name__, path_template="/scan") @@ -65,7 +71,8 @@ def update_cfg(contents): dbc.CardBody([ html.Div([ dbc.Label("Name of the dataset to create:"), - dbc.Input(id="dataset-name", placeholder="Dataset name", className="mb-3", invalid=True), + dbc.Input(id="dataset-name", placeholder="Dataset name", + className="mb-3", invalid=True, persistence=True), dbc.FormText(dcc.Markdown( "The list of forbidden characters is: " + ', '.join([f'`{c}`' for c in FORBIDDEN_CHAR]) )), @@ -83,12 +90,18 @@ def update_cfg(contents): children=[ dbc.CardHeader("Scan"), dbc.CardBody([ - dcc.Loading([ - dbc.Button('Start scanning', id='scan-button') - ]), - dcc.Markdown(id='scan-response', children="_Run a scan first..._"), - ] - ), + dbc.Row([ + dbc.Col([ + dcc.Loading([ + dbc.Button('Start scanning', id='scan-button') + ]), + ], width=6), + dbc.Col([ + dbc.Button('Preview', id='preview-button', disabled=True) + ], width=6), + dcc.Markdown(id='scan-response', children="_Run a scan first..._"), + ]) + ]), dbc.CardFooter([ dbc.Accordion( dbc.AccordionItem(children=[ @@ -110,7 +123,7 @@ def update_cfg(contents): children=[ dbc.CardHeader("Upload"), dbc.CardBody([ - dcc.Loading([dbc.Button('Upload', id='upload-archive', disabled=True)]), + dcc.Loading([dbc.Button('Upload', id='upload-button', disabled=True)]), dcc.Markdown(id='upload-response', children="_Upload first..._"), ] ), @@ -128,10 +141,18 @@ def update_cfg(contents): ) ] +preview_modal = dbc.Modal([ + dbc.ModalHeader( + dbc.ModalTitle(id='preview-title', children="Dataset preview") + ), + dbc.ModalBody(id='preview-carousel'), +], id="modal-fs", fullscreen=True, ) + # Callback to validate the selected dataset name: @callback(Output('dataset-name', 'valid'), Output('dataset-name', 'invalid'), + Output('dataset-id', 'data'), Input('dataset-name', 'value'), State('dataset-dict', 'data'), prevent_initial_call=True) @@ -155,20 +176,24 @@ def validate_dataset_name(dataset_name, dataset_dict): The `valid` state of the 'dataset-name' `Input` component. bool The `invalid` state of the 'dataset-name' `Input` component. + str + The name of the dataset. """ if dataset_name not in list(dataset_dict.keys()) and sum( [letter in FORBIDDEN_CHAR for letter in dataset_name]) == 0: - return True, False + return True, False, dataset_name else: - return False, True + return False, True, dataset_name @callback(Output('scan-button', 'disabled'), Output('scan-response', 'children'), Output('scan-output', 'children'), + Output('preview-button', 'disabled'), + Output('upload-button', 'disabled'), Input('scan-button', 'n_clicks'), - State('cfg-toml', 'value'), - State('dataset-name', 'data'), + State('scan-cfg-toml', 'value'), + State('dataset-name', 'value'), prevent_initial_call=True) def run_scan(n_clicks, cfg, dataset_name): task = "Scan" # we will run a scan task @@ -191,7 +216,74 @@ def run_scan(n_clicks, cfg, dataset_name): with open(dataset_path / log_fname, 'rb') as f: log = "```\n" + "".join([line.decode() for line in f.readlines()]) + "```" - return True, success, log + return True, success, log, False, False + + +# Callback of the "upload-button" button: +@callback(Output('upload-response', 'children'), + Output('upload-output', 'children'), + Input('upload-button', 'n_clicks'), + State('dataset-id', 'data'), + State('rest-api-host', 'data'), + State('rest-api-port', 'data'), + prevent_initial_call=True) +def upload_archive(n_clicks, scan_id, host, port): + """Create an archive of the local dataset and send it to the PlantDB REST API using a POST request.""" + # Local path to search for files to archive + scan_path = temp_scan_dir(scan_id) + # List to store file paths to archive + file_paths = [] + # Recursively search for files + for root, dirs, files in os.walk(scan_path): + for file in files: + file_path = os.path.join(root, file) + file_paths.append(file_path) + + # Create a zip file in memory + zip_data = BytesIO() + with ZipFile(zip_data, mode='w') as zip_file: + for file_path in file_paths: + # Check if the file exists + if os.path.isfile(file_path): + # Add the file to the zip, + # removing the path to the scan directory not to get the full path in archived file names + zip_file.write(file_path, + arcname=file_path.replace(str(scan_path) + '/', '')) + else: + print(f"Warning: {file_path} is not a file and will be skipped.") + + # Send the POST request + url = f"{base_url(host, port)}/archive/{scan_id}" + files = {'zip_file': ('archive.zip', zip_data.getvalue())} + response = requests.post(url, files=files) + + # Check the response to the POST request: + if response.ok: + return 'Zip file uploaded successfully', "```\n" + "\n".join(response.json()['files']) + "```" + else: + return 'Error uploading zip file', "```\n" + response.text + "```" + + +def preview_carousel(img_uri_list): + carousel = dbc.Carousel( + items=[{"key": i, "src": img_uri, "caption": f"Image {str(i).zfill(5)}"} for i, img_uri in + enumerate(img_uri_list)], + controls=True, indicators=True, className="carousel-fade", + ) + return carousel + + +@callback(Output('preview-title', 'children'), + Output('preview-carousel', 'children'), + Input('preview-button', 'n_clicks'), + State('dataset-id', 'data'), + prevent_initial_call=True) +def preview(n_clicks, dataset_id): + # Local path to search for image files to preview: + scan_path = temp_scan_dir(dataset_id) + img_path = scan_path / 'images' + img_uri_list = [p for p in img_path.iterdir() if p.is_file()] + return f"'{dataset_id}' dataset preview", preview_carousel(img_uri_list) def layout(dataset_id=None, **kwargs): @@ -210,9 +302,9 @@ def layout(dataset_id=None, **kwargs): return html.Div([ # Store the dataset id to use in the callback. dcc.Store(id='dataset-id', data=dataset_id), - # Content of the reconstruction app: + # Content of the scan page: dbc.Row( - id="app-content", + id="scan-page-content", children=[ dbc.Col(configuration_card, md=6), dbc.Col(dataset_name_card + [html.Br()] + scan_card + [html.Br()] + upload_card, md=6)