diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 423d425..164d004 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,6 +50,11 @@ jobs: pip install coverage pip install -e ".[testing]" working-directory: src/client + + - name: Install server dependencies (for communication tests) + run: | + pip install -e ".[testing]" + working-directory: src/server - name: Test with pytest run: | diff --git a/src/client/dcp_client/app.py b/src/client/dcp_client/app.py index b5e89c2..912bd01 100644 --- a/src/client/dcp_client/app.py +++ b/src/client/dcp_client/app.py @@ -65,30 +65,44 @@ def __init__( self.seg_filepaths = [] def upload_data_to_server(self): + """ + Uploads the train and eval data to the server. + """ self.syncer.first_sync(path=self.train_data_path) self.syncer.first_sync(path=self.eval_data_path) + + def try_server_connection(self): + """ + Checks if the ml model is connected to server and attempts to connect if not. + """ + connection_success = self.ml_model.connect(ip=self.server_ip, port=self.server_port) + return connection_success def run_train(self): """ Checks if the ml model is connected to the server, connects if not (and if possible), and trains the model with all data available in train_data_path """ - if not self.ml_model.is_connected: - connection_success = self.ml_model.connect(ip=self.server_ip, port=self.server_port) - if not connection_success: return "Connection could not be established. Please check if the server is running and try again." + if not self.ml_model.is_connected and not self.try_server_connection(): + message_title = "Warning" + message_text = "Connection could not be established. Please check if the server is running and try again." + return message_text, message_title # if syncer.host name is None then local machine is used to train + message_title = "Information" if self.syncer.host_name=="local": - return self.ml_model.run_train(self.train_data_path) + message_text = self.ml_model.run_train(self.train_data_path) else: srv_relative_path = self.syncer.sync(src='client', dst='server', path=self.train_data_path) - return self.ml_model.run_train(srv_relative_path) - + message_text = self.ml_model.run_train(srv_relative_path) + if message_text is None: + message_text = "An error has occured on the server. Please check your image data and configurations. If the problem persists contact your software provider." + message_title = "Error" + return message_text, message_title def run_inference(self): """ Checks if the ml model is connected to the server, connects if not (and if possible), and runs inference on all images in eval_data_path """ - if not self.ml_model.is_connected: - connection_success = self.ml_model.connect(ip=self.server_ip, port=self.server_port) - if not connection_success: - message_text = "Connection could not be established. Please check if the server is running and try again." - return message_text, "Warning" - + if not self.ml_model.is_connected and not self.try_server_connection(): + message_title = "Warning" + message_text = "Connection could not be established. Please check if the server is running and try again." + return message_text, message_title + if self.syncer.host_name=="local": # model serving directly from local list_of_files_not_suported = self.ml_model.run_inference(self.eval_data_path) @@ -98,16 +112,19 @@ def run_inference(self): list_of_files_not_suported = self.ml_model.run_inference(srv_relative_path) # sync data so that client gets new masks _ = self.syncer.sync(src='server', dst='client', path=self.eval_data_path) - # check if serving could not be performed for some files and prepare message - list_of_files_not_suported = list(list_of_files_not_suported) - if len(list_of_files_not_suported) > 0: - message_text = "Image types not supported. Only 2D and 3D image shapes currently supported. 3D stacks must be of type grayscale. \ - Currently supported image file formats are: " + ", ".join(settings.accepted_types)+ ". The files that were not supported are: " + ", ".join(list_of_files_not_suported) - message_title = "Warning" + if list_of_files_not_suported is None: + message_text = "An error has occured on the server. Please check your image data and configurations. If the problem persists contact your software provider." + message_title = "Error" else: - message_text = "Success! Masks generated for all images" - message_title="Success" + list_of_files_not_suported = list(list_of_files_not_suported) + if len(list_of_files_not_suported) > 0: + message_text = "Image types not supported. Only 2D and 3D image shapes currently supported. 3D stacks must be of type grayscale. \ + Currently supported image file formats are: " + ", ".join(settings.accepted_types)+ ". The files that were not supported are: " + ", ".join(list_of_files_not_suported) + message_title = "Warning" + else: + message_text = "Success! Masks generated for all images" + message_title = "Information" return message_text, message_title def load_image(self, image_name=None): diff --git a/src/client/dcp_client/gui/_my_widget.py b/src/client/dcp_client/gui/_my_widget.py new file mode 100644 index 0000000..38a3744 --- /dev/null +++ b/src/client/dcp_client/gui/_my_widget.py @@ -0,0 +1,35 @@ +from PyQt5.QtWidgets import QWidget, QMessageBox +from PyQt5.QtCore import QTimer + +class MyWidget(QWidget): + + msg = None + sim = False # will be used for testing to simulate user click + + def create_warning_box(self, message_text: str=" ", message_title: str="Information", add_cancel_btn: bool=False, custom_dialog=None) -> None: + #setup box + if custom_dialog is not None: self.msg = custom_dialog + else: self.msg = QMessageBox() + + if message_title=="Warning": + message_type = QMessageBox.Warning + elif message_title=="Error": + message_type = QMessageBox.Critical + else: + message_type = QMessageBox.Information + self.msg.setIcon(message_type) + self.msg.setText(message_text) + self.msg.setWindowTitle(message_title) + # if specified add a cancel button else only an ok + if add_cancel_btn: + self.msg.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) + # simulate button click if specified - workaround used for testing + if self.sim: QTimer.singleShot(0, self.msg.button(QMessageBox.Cancel).clicked) + else: + self.msg.setStandardButtons(QMessageBox.Ok) + # simulate button click if specified - workaround used for testing + if self.sim: QTimer.singleShot(0, self.msg.button(QMessageBox.Ok).clicked) + # return if user clicks Ok and False otherwise + usr_response = self.msg.exec() + if usr_response == QMessageBox.Ok: return True + else: return False \ No newline at end of file diff --git a/src/client/dcp_client/gui/main_window.py b/src/client/dcp_client/gui/main_window.py index d4463c6..2dc4cb6 100644 --- a/src/client/dcp_client/gui/main_window.py +++ b/src/client/dcp_client/gui/main_window.py @@ -1,37 +1,68 @@ from __future__ import annotations from typing import TYPE_CHECKING -from PyQt5.QtWidgets import QWidget, QPushButton, QVBoxLayout, QFileSystemModel, QHBoxLayout, QLabel, QTreeView -from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QPushButton, QVBoxLayout, QFileSystemModel, QHBoxLayout, QLabel, QTreeView, QProgressBar +from PyQt5.QtCore import Qt, QThread, pyqtSignal from dcp_client.utils import settings -from dcp_client.utils.utils import IconProvider, create_warning_box +from dcp_client.utils.utils import IconProvider + from dcp_client.gui.napari_window import NapariWindow +from dcp_client.gui._my_widget import MyWidget if TYPE_CHECKING: from dcp_client.app import Application +class WorkerThread(QThread): + ''' Worker thread for displaying Pulse ProgressBar during model serving ''' + task_finished = pyqtSignal(tuple) + def __init__(self, app: Application, task: str = None, parent = None,): + super().__init__(parent) + self.app = app + self.task = task + + def run(self): + ''' Once run_inference the tuple of (message_text, message_title) will be returned to on_finished''' + try: + if self.task == 'inference': + message_text, message_title = self.app.run_inference() + elif self.task == 'train': + message_text, message_title = self.app.run_train() + else: + message_text, message_title = "Unknown task", "Error" + + except Exception as e: + # Log any exceptions that might occur in the thread + message_text, message_title = f"Exception in WorkerThread: {e}", "Error" -class MainWindow(QWidget): - '''Main Window Widget object. + self.task_finished.emit((message_text, message_title)) + +class MainWindow(MyWidget): + ''' + Main Window Widget object. Opens the main window of the app where selected images in both directories are listed. User can view the images, train the mdoel to get the labels, and visualise the result. :param eval_data_path: Chosen path to images without labeles, selected by the user in the WelcomeWindow :type eval_data_path: string :param train_data_path: Chosen path to images with labeles, selected by the user in the WelcomeWindow :type train_data_path: string - ''' + ''' def __init__(self, app: Application): super().__init__() self.app = app self.title = "Data Overview" + self.worker_thread = None self.main_window() def main_window(self): + ''' + Sets up the GUI + ''' self.setWindowTitle(self.title) #self.resize(1000, 1500) - self.main_layout = QHBoxLayout() + main_layout = QVBoxLayout() + dir_layout = QHBoxLayout() self.uncurated_layout = QVBoxLayout() self.inprogress_layout = QVBoxLayout() @@ -61,7 +92,7 @@ def main_window(self): self.inference_button.clicked.connect(self.on_run_inference_button_clicked) # add selected image self.uncurated_layout.addWidget(self.inference_button, alignment=Qt.AlignCenter) - self.main_layout.addLayout(self.uncurated_layout) + dir_layout.addLayout(self.uncurated_layout) # In progress layout self.inprogr_dir_layout = QVBoxLayout() @@ -87,7 +118,7 @@ def main_window(self): self.launch_nap_button.clicked.connect(self.on_launch_napari_button_clicked) # add selected image self.inprogress_layout.addWidget(self.launch_nap_button, alignment=Qt.AlignCenter) - self.main_layout.addLayout(self.inprogress_layout) + dir_layout.addLayout(self.inprogress_layout) # Curated layout self.train_dir_layout = QVBoxLayout() @@ -112,55 +143,113 @@ def main_window(self): self.train_button = QPushButton("Train Model", self) self.train_button.clicked.connect(self.on_train_button_clicked) # add selected image self.curated_layout.addWidget(self.train_button, alignment=Qt.AlignCenter) + dir_layout.addLayout(self.curated_layout) + + main_layout.addLayout(dir_layout) - self.main_layout.addLayout(self.curated_layout) + # add progress bar + progress_layout = QHBoxLayout() + progress_layout.addStretch(1) + self.progress_bar = QProgressBar(self) + self.progress_bar.setRange(0,1) + progress_layout.addWidget(self.progress_bar) + main_layout.addLayout(progress_layout) - self.setLayout(self.main_layout) + self.setLayout(main_layout) self.show() def on_item_train_selected(self, item): + ''' + Is called once an image is selected in the 'curated dataset' folder + ''' self.app.cur_selected_img = item.data() self.app.cur_selected_path = self.app.train_data_path def on_item_eval_selected(self, item): + ''' + Is called once an image is selected in the 'uncurated dataset' folder + ''' self.app.cur_selected_img = item.data() self.app.cur_selected_path = self.app.eval_data_path def on_item_inprogr_selected(self, item): + ''' + Is called once an image is selected in the 'in progress' folder + ''' self.app.cur_selected_img = item.data() self.app.cur_selected_path = self.app.inprogr_data_path def on_train_button_clicked(self): - message_text = self.app.run_train() - _ = create_warning_box(message_text) + ''' + Is called once user clicks the "Train Model" button + ''' + self.train_button.setEnabled(False) + self.progress_bar.setRange(0,0) + # initialise the worker thread + self.worker_thread = WorkerThread(app=self.app, task='train') + self.worker_thread.task_finished.connect(self.on_finished) + # start the worker thread to train + self.worker_thread.start() def on_run_inference_button_clicked(self): - message_text, message_title = self.app.run_inference() - _ = create_warning_box(message_text, message_title) + ''' + Is called once user clicks the "Generate Labels" button + ''' + self.inference_button.setEnabled(False) + self.progress_bar.setRange(0,0) + # initialise the worker thread + self.worker_thread = WorkerThread(app=self.app, task='inference') + self.worker_thread.task_finished.connect(self.on_finished) + # start the worker thread to run inference + self.worker_thread.start() def on_launch_napari_button_clicked(self): ''' Launches the napari window after the image is selected. ''' if not self.app.cur_selected_img or '_seg.tiff' in self.app.cur_selected_img: - message_text = "Please first select an image you wish to visualise. The selected image must be an original images, not a mask." - _ = create_warning_box(message_text, message_title="Warning") + message_text = "Please first select an image you wish to visualise. The selected image must be an original image, not a mask." + _ = self.create_warning_box(message_text, message_title="Warning") else: self.nap_win = NapariWindow(self.app) self.nap_win.show() + def on_finished(self, result): + ''' + Is called once the worker thread emits the on finished signal + ''' + # Stop the pulsation + self.progress_bar.setRange(0,1) + # Display message of result + message_text, message_title = result + _ = self.create_warning_box(message_text, message_title) + # Re-enable buttons + self.inference_button.setEnabled(True) + self.train_button.setEnabled(True) + # Delete the worker thread when it's done + self.worker_thread.quit() + self.worker_thread.wait() + self.worker_thread.deleteLater() + self.worker_thread = None # Set to None to indicate it's no longer in use + + if __name__ == "__main__": import sys from PyQt5.QtWidgets import QApplication - from app import Application - from bentoml_model import BentomlModel - from fsimagestorage import FilesystemImageStorage - import settings + from dcp_client.app import Application + from dcp_client.utils.bentoml_model import BentomlModel + from dcp_client.utils.fsimagestorage import FilesystemImageStorage + from dcp_client.utils import settings + from dcp_client.utils.sync_src_dst import DataRSync settings.init() image_storage = FilesystemImageStorage() ml_model = BentomlModel() + data_sync = DataRSync(user_name="local", + host_name="local", + server_repo_path=None) app = QApplication(sys.argv) app_ = Application(ml_model=ml_model, + syncer=data_sync, image_storage=image_storage, server_ip='0.0.0.0', server_port=7010, diff --git a/src/client/dcp_client/gui/napari_window.py b/src/client/dcp_client/gui/napari_window.py index e0451dd..74fbdcc 100644 --- a/src/client/dcp_client/gui/napari_window.py +++ b/src/client/dcp_client/gui/napari_window.py @@ -7,9 +7,10 @@ if TYPE_CHECKING: from dcp_client.app import Application -from dcp_client.utils import utils +from dcp_client.utils.utils import get_path_stem +from dcp_client.gui._my_widget import MyWidget -class NapariWindow(QWidget): +class NapariWindow(MyWidget): '''Napari Window Widget object. Opens the napari image viewer to view and fix the labeles. :param app: @@ -27,9 +28,9 @@ def __init__(self, app: Application): # Set the viewer self.viewer = napari.Viewer(show=False) - self.viewer.add_image(img, name=utils.get_path_stem(self.app.cur_selected_img)) + self.viewer.add_image(img, name=get_path_stem(self.app.cur_selected_img)) for seg_file in self.app.seg_filepaths: - self.viewer.add_labels(self.app.load_image(seg_file), name=utils.get_path_stem(seg_file)) + self.viewer.add_labels(self.app.load_image(seg_file), name=get_path_stem(seg_file)) main_window = self.viewer.window._qt_window layout = QVBoxLayout() @@ -56,7 +57,7 @@ def on_add_to_curated_button_clicked(self): ''' if self.app.cur_selected_path == str(self.app.train_data_path): message_text = "Image is already in the \'Curated data\' folder and should not be changed again" - _ = utils.create_warning_box(message_text, message_title="Warning") + _ = self.create_warning_box(message_text, message_title="Warning") return # take the name of the currently selected layer (by the user) @@ -64,7 +65,7 @@ def on_add_to_curated_button_clicked(self): # TODO if more than one item is selected this will break! if '_seg' not in cur_seg_selected: message_text = "Please select the segmenation you wish to save from the layer list" - _ = utils.create_warning_box(message_text, message_title="Warning") + _ = self.create_warning_box(message_text, message_title="Warning") return seg = self.viewer.layers[cur_seg_selected].data @@ -87,7 +88,7 @@ def on_add_to_inprogress_button_clicked(self): # TODO: Do we allow this? What if they moved it by mistake? User can always manually move from their folders?) if self.app.cur_selected_path == str(self.app.train_data_path): message_text = "Images from '\Curated data'\ folder can not be moved back to \'Curatation in progress\' folder." - _ = utils.create_warning_box(message_text, message_title="Warning") + _ = self.create_warning_box(message_text, message_title="Warning") return # take the name of the currently selected layer (by the user) @@ -95,7 +96,7 @@ def on_add_to_inprogress_button_clicked(self): # TODO if more than one item is selected this will break! if '_seg' not in cur_seg_selected: message_text = "Please select the segmenation you wish to save from the layer list" - _ = utils.create_warning_box(message_text, message_title="Warning") + _ = self.create_warning_box(message_text, message_title="Warning") return # Move original image diff --git a/src/client/dcp_client/gui/welcome_window.py b/src/client/dcp_client/gui/welcome_window.py index 5c385a5..f32d5a7 100644 --- a/src/client/dcp_client/gui/welcome_window.py +++ b/src/client/dcp_client/gui/welcome_window.py @@ -1,17 +1,16 @@ from __future__ import annotations from typing import TYPE_CHECKING -from PyQt5.QtWidgets import QWidget, QPushButton, QVBoxLayout, QHBoxLayout, QLabel, QFileDialog, QLineEdit +from PyQt5.QtWidgets import QPushButton, QVBoxLayout, QHBoxLayout, QLabel, QFileDialog, QLineEdit from PyQt5.QtCore import Qt from dcp_client.gui.main_window import MainWindow -from dcp_client.utils.utils import create_warning_box +from dcp_client.gui._my_widget import MyWidget if TYPE_CHECKING: from dcp_client.app import Application - -class WelcomeWindow(QWidget): +class WelcomeWindow(MyWidget): '''Welcome Window Widget object. The first window of the application providing a dialog that allows users to select directories. Currently supported image file types that can be selected for segmentation are: .jpg, .jpeg, .png, .tiff, .tif. @@ -50,18 +49,18 @@ def __init__(self, app: Application): self.path_layout.addWidget(self.inprogr_textbox) self.path_layout.addWidget(self.train_textbox) - file_open_button_val = QPushButton('Browse',self) - file_open_button_val.show() - file_open_button_val.clicked.connect(self.browse_eval_clicked) - file_open_button_prog = QPushButton('Browse',self) - file_open_button_prog.show() - file_open_button_prog.clicked.connect(self.browse_inprogr_clicked) - file_open_button_train = QPushButton('Browse',self) - file_open_button_train.show() - file_open_button_train.clicked.connect(self.browse_train_clicked) - self.button_layout.addWidget(file_open_button_val) - self.button_layout.addWidget(file_open_button_prog) - self.button_layout.addWidget(file_open_button_train) + self.file_open_button_val = QPushButton('Browse',self) + self.file_open_button_val.show() + self.file_open_button_val.clicked.connect(self.browse_eval_clicked) + self.file_open_button_prog = QPushButton('Browse',self) + self.file_open_button_prog.show() + self.file_open_button_prog.clicked.connect(self.browse_inprogr_clicked) + self.file_open_button_train = QPushButton('Browse',self) + self.file_open_button_train.show() + self.file_open_button_train.clicked.connect(self.browse_train_clicked) + self.button_layout.addWidget(self.file_open_button_val) + self.button_layout.addWidget(self.file_open_button_prog) + self.button_layout.addWidget(self.file_open_button_train) input_layout.addLayout(self.text_layout) input_layout.addLayout(self.path_layout) @@ -72,10 +71,11 @@ def __init__(self, app: Application): self.start_button.setFixedSize(120, 30) self.start_button.show() # check if we need to upload data to server + self.done_upload = False # we only do once if self.app.syncer.host_name == "local": self.start_button.clicked.connect(self.start_main) else: - self.start_button.clicked.connect(self.start_upload) + self.start_button.clicked.connect(self.start_upload_and_main) self.main_layout.addWidget(self.start_button, alignment=Qt.AlignCenter) self.setLayout(self.main_layout) @@ -86,13 +86,15 @@ def browse_eval_clicked(self): Activates when the user clicks the button to choose the evaluation directory (QFileDialog) and displays the name of the evaluation directory chosen in the validation textbox line (QLineEdit). ''' - - fd = QFileDialog() - fd.setFileMode(QFileDialog.Directory) - if fd.exec_(): - self.app.eval_data_path = fd.selectedFiles()[0] - self.val_textbox.setText(self.app.eval_data_path) - + self.fd = QFileDialog() + try: + self.fd.setFileMode(QFileDialog.Directory) + if self.fd.exec_(): + self.app.eval_data_path = self.fd.selectedFiles()[0] + self.val_textbox.setText(self.app.eval_data_path) + finally: + self.fd = None + def browse_train_clicked(self): ''' Activates when the user clicks the button to choose the train directory (QFileDialog) and @@ -118,7 +120,6 @@ def browse_inprogr_clicked(self): self.app.inprogr_data_path = fd.selectedFiles()[0] #TODO: case when browse is clicked but nothing is specified - currently it is filled with os.getcwd() self.inprogr_textbox.setText(self.app.inprogr_data_path) - def start_main(self): ''' Starts the main window after the user clicks 'Start' and only if both evaluation and train directories are chosen. @@ -129,15 +130,20 @@ def start_main(self): self.mw = MainWindow(self.app) else: message_text = "You need to specify a folder both for your uncurated and curated dataset (even if the curated folder is currently empty). Please go back and select folders for both." - _ = create_warning_box(message_text, message_title="Warning") - - def start_upload(self): - message_text = ("Your current configurations are set to run some operations on the cloud. \n" - "For this we need to upload your data to our server." - "We will now upload your data. Click ok to continue. \n" - "If you do not agree close the application and contact your software provider.") - usr_response = create_warning_box(message_text, message_title="Warning", add_cancel_btn=True) - if usr_response: self.app.upload_data_to_server() - self.hide() - self.mw = MainWindow(self.app) + _ = self.create_warning_box(message_text, message_title="Warning") + + def start_upload_and_main(self): + ''' + If the configs are set to use remote not local server then the user is asked to confirm the upload of their data + to the server and the upload starts before launching the main window. + ''' + if self.done_upload is False: + message_text = ("Your current configurations are set to run some operations on the cloud. \n" + "For this we need to upload your data to our server." + "We will now upload your data. Click ok to continue. \n" + "If you do not agree close the application and contact your software provider.") + usr_response = self.create_warning_box(message_text, message_title="Warning", add_cancel_btn=True) + if usr_response: self.app.upload_data_to_server() + self.done_upload = True + self.start_main() \ No newline at end of file diff --git a/src/client/dcp_client/utils/bentoml_model.py b/src/client/dcp_client/utils/bentoml_model.py index 5b38f29..a6e7264 100644 --- a/src/client/dcp_client/utils/bentoml_model.py +++ b/src/client/dcp_client/utils/bentoml_model.py @@ -1,6 +1,7 @@ import asyncio from typing import Optional from bentoml.client import Client as BentoClient +from bentoml.exceptions import BentoMLException from dcp_client.app import Model @@ -24,15 +25,19 @@ def is_connected(self): return bool(self.client) async def _run_train(self, data_path): - response = await self.client.async_train(data_path) - return response + try: + response = await self.client.async_train(data_path) + return response + except BentoMLException: return None def run_train(self, data_path): return asyncio.run(self._run_train(data_path)) async def _run_inference(self, data_path): - response = await self.client.async_segment_image(data_path) - return response + try: + response = await self.client.async_segment_image(data_path) + return response + except BentoMLException: return None def run_inference(self, data_path): list_of_files_not_suported = asyncio.run(self._run_inference(data_path)) diff --git a/src/client/dcp_client/utils/utils.py b/src/client/dcp_client/utils/utils.py index 1bf1bc7..0560d8c 100644 --- a/src/client/dcp_client/utils/utils.py +++ b/src/client/dcp_client/utils/utils.py @@ -1,5 +1,5 @@ -from PyQt5.QtWidgets import QFileIconProvider, QMessageBox -from PyQt5.QtCore import QSize, QTimer +from PyQt5.QtWidgets import QFileIconProvider +from PyQt5.QtCore import QSize from PyQt5.QtGui import QPixmap, QIcon from pathlib import Path, PurePath @@ -13,8 +13,9 @@ def __init__(self) -> None: self.ICON_SIZE = QSize(512,512) def icon(self, type: 'QFileIconProvider.IconType'): - - fn = type.filePath() + try: + fn = type.filePath() + except AttributeError: return super().icon(type) # TODO handle exception differently? if fn.endswith(settings.accepted_types): a = QPixmap(self.ICON_SIZE) @@ -23,27 +24,6 @@ def icon(self, type: 'QFileIconProvider.IconType'): else: return super().icon(type) -def create_warning_box(message_text, message_title="Warning", add_cancel_btn=False, custom_dialog=None, sim=False): - #setup box - if custom_dialog is None: msg = QMessageBox() - else: msg = custom_dialog - msg.setIcon(QMessageBox.Information) - msg.setText(message_text) - msg.setWindowTitle(message_title) - # if specified add a cancel button else only an ok - if add_cancel_btn: - msg.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) - # simulate button click if specified - workaround used for testing - if sim: QTimer.singleShot(0, msg.button(QMessageBox.Cancel).clicked) - else: - msg.setStandardButtons(QMessageBox.Ok) - # simulate button click if specified - workaround used for testing - if sim: QTimer.singleShot(0, msg.button(QMessageBox.Ok).clicked) - # return if user clicks Ok and False otherwise - usr_response = msg.exec() - if usr_response == QMessageBox.Ok: return True - else: return False - def read_config(name, config_path = 'config.cfg') -> dict: """Reads the configuration file diff --git a/src/client/requirements.txt b/src/client/requirements.txt index 56eaff3..798b769 100644 --- a/src/client/requirements.txt +++ b/src/client/requirements.txt @@ -1,3 +1,4 @@ napari[pyqt5]>=0.4.17 bentoml[grpc]==1.0.16 -pytest>=7.4.3 \ No newline at end of file +pytest>=7.4.3 +pytest-qt>=4.2.0 \ No newline at end of file diff --git a/src/client/test/test_app.py b/src/client/test/test_app.py index f2a29ca..cdab8d2 100644 --- a/src/client/test/test_app.py +++ b/src/client/test/test_app.py @@ -1,54 +1,101 @@ import os import sys -from skimage import data -from skimage.io import imsave +sys.path.append("../") import pytest +import subprocess +import time -sys.path.append("../") +from skimage import data +from skimage.io import imsave from dcp_client.app import Application from dcp_client.utils.bentoml_model import BentomlModel from dcp_client.utils.fsimagestorage import FilesystemImageStorage from dcp_client.utils.sync_src_dst import DataRSync - @pytest.fixture def app(): - img = data.astronaut() - img2 = data.cat() - os.mkdir('in_prog') + img1 = data.astronaut() + img2 = data.coffee() + img3 = data.cat() - imsave('in_prog/test_img.png', img) - imsave('in_prog/test_img2.png', img2) + if not os.path.exists('in_prog'): + os.mkdir('in_prog') + imsave('in_prog/coffee.png', img2) - rsyncer = DataRSync(user_name="local", host_name="local", server_repo_path='.') - app = Application(BentomlModel(), rsyncer, FilesystemImageStorage(), "0.0.0.0", 7010) + if not os.path.exists('eval_data_path'): + os.mkdir('eval_data_path') + imsave('eval_data_path/cat.png', img3) - app.cur_selected_img = 'test_img.png' - app.cur_selected_path = 'in_prog' + rsyncer = DataRSync(user_name="local", host_name="local", server_repo_path='.') + app = Application(BentomlModel(), + rsyncer, + FilesystemImageStorage(), + "0.0.0.0", + 7010, + os.path.join(os.getcwd(), 'eval_data_path')) - return app, img, img2 + return app, img1, img2, img3 def test_load_image(app): - app, img, img2 = app # Unpack the app, img, and img2 from the fixture + app, img, img2, _ = app # Unpack the app, img, and img2 from the fixture + + app.cur_selected_img = 'coffee.png' + app.cur_selected_path = 'in_prog' img_test = app.load_image() # if image_name is None assert img.all() == img_test.all() - img_test2 = app.load_image('test_img2.png') # if a filename is given + app.cur_selected_path = 'eval_data_path' + img_test2 = app.load_image('cat.png') # if a filename is given assert img2.all() == img_test2.all() - # delete everything we created - os.remove('in_prog/test_img.png') - os.remove('in_prog/test_img2.png') - os.rmdir('in_prog') - +def test_run_inference_no_connection(app): + app, _, _, _ = app + message_text, message_title = app.run_inference() + assert message_text=="Connection could not be established. Please check if the server is running and try again." + assert message_title=="Warning" + +def test_run_inference_run(app): + app, _, _, _ = app + # start the sevrer in the background locally + command = [ + "bentoml", + "serve", + '--working-dir', + '../server/dcp_server', + "service:svc", + "--reload", + "--port=7010", + ] + process = subprocess.Popen(command) + time.sleep(60) # and wait until it is setup + # then do model serving + message_text, message_title = app.run_inference() + # and assert returning message + assert message_text== "Success! Masks generated for all images" + assert message_title=="Information" + # finally clean up process + process.terminate() + process.wait() + process.kill() + +def test_search_segs(app): + app, _, _, _ = app + app.cur_selected_img = 'cat.png' + app.cur_selected_path = 'eval_data_path' + app.search_segs() + res = app.seg_filepaths + assert len(res)==1 + assert res[0]=='cat_seg.tiff' + # also remove the seg as it is not needed for other scripts + os.remove('eval_data_path/cat_seg.tiff') + + +''' def test_run_train(): pass -def test_run_inference(): - pass - def test_save_image(): pass @@ -58,12 +105,7 @@ def test_move_images(): def test_delete_images(): pass -def test_search_segs(): - pass - - - - +''' diff --git a/src/client/test/test_main_window.py b/src/client/test/test_main_window.py new file mode 100644 index 0000000..ca80c6d --- /dev/null +++ b/src/client/test/test_main_window.py @@ -0,0 +1,172 @@ +import os +import pytest +import sys +sys.path.append('../') + +from skimage import data +from skimage.io import imsave + +from PyQt5.QtCore import Qt +from PyQt5.QtTest import QTest + +from dcp_client.gui.main_window import MainWindow +from dcp_client.app import Application +from dcp_client.utils.bentoml_model import BentomlModel +from dcp_client.utils.fsimagestorage import FilesystemImageStorage +from dcp_client.utils.sync_src_dst import DataRSync +from dcp_client.utils import settings + +@pytest.fixture() +def setup_global_variable(): + settings.accepted_types = (".jpg", ".jpeg", ".png", ".tiff", ".tif") + yield settings.accepted_types + +@pytest.fixture +def app(qtbot, setup_global_variable): + + settings.accepted_types = setup_global_variable + + img1 = data.astronaut() + img2 = data.coffee() + img3 = data.cat() + + if not os.path.exists('train_data_path'): + os.mkdir('train_data_path') + imsave('train_data_path/astronaut.png', img1) + + if not os.path.exists('in_prog'): + os.mkdir('in_prog') + imsave('in_prog/coffee.png', img2) + + if not os.path.exists('eval_data_path'): + os.mkdir('eval_data_path') + imsave('eval_data_path/cat.png', img3) + + rsyncer = DataRSync(user_name="local", host_name="local", server_repo_path='.') + application = Application(BentomlModel(), + rsyncer, + FilesystemImageStorage(), + "0.0.0.0", + 7010, + 'eval_data_path', + 'train_data_path', + 'in_prog') + # Create an instance of MainWindow + widget = MainWindow(application) + qtbot.addWidget(widget) + yield widget + widget.close() + +def test_main_window_setup(qtbot, app, setup_global_variable): + settings.accepted_types = setup_global_variable + assert app.title == "Data Overview" + +def test_item_train_selected(qtbot, app, setup_global_variable): + settings.accepted_types = setup_global_variable + # Select the first item in the tree view + #index = app.list_view_train.model().index(0, 0) + index = app.list_view_train.indexAt(app.list_view_train.viewport().rect().topLeft()) + pos = app.list_view_train.visualRect(index).center() + # Simulate file click + QTest.mouseClick(app.list_view_train.viewport(), + Qt.LeftButton, + pos=pos) + + app.on_item_train_selected(index) + # Assert that the selected item matches the expected item + assert app.list_view_train.selectionModel().currentIndex() == index + assert app.app.cur_selected_img=='astronaut.png' + assert app.app.cur_selected_path==app.app.train_data_path + +def test_item_inprog_selected(qtbot, app, setup_global_variable): + settings.accepted_types = setup_global_variable + # Select the first item in the tree view + index = app.list_view_inprogr.indexAt(app.list_view_inprogr.viewport().rect().topLeft()) + pos = app.list_view_inprogr.visualRect(index).center() + # Simulate file click + QTest.mouseClick(app.list_view_inprogr.viewport(), + Qt.LeftButton, + pos=pos) + app.on_item_inprogr_selected(index) + # Assert that the selected item matches the expected item + assert app.list_view_inprogr.selectionModel().currentIndex() == index + assert app.app.cur_selected_img == "coffee.png" + assert app.app.cur_selected_path == app.app.inprogr_data_path + +def test_item_eval_selected(qtbot, app, setup_global_variable): + settings.accepted_types = setup_global_variable + # Select the first item in the tree view + index = app.list_view_eval.indexAt(app.list_view_eval.viewport().rect().topLeft()) + pos = app.list_view_eval.visualRect(index).center() + # Simulate file click + QTest.mouseClick(app.list_view_eval.viewport(), + Qt.LeftButton, + pos=pos) + app.on_item_eval_selected(index) + # Assert that the selected item matches the expected item + assert app.list_view_eval.selectionModel().currentIndex() == index + assert app.app.cur_selected_img=='cat.png' + assert app.app.cur_selected_path==app.app.eval_data_path + +def test_train_button_click(qtbot, app): + # Click the "Train Model" button + app.sim = True + QTest.mouseClick(app.train_button, Qt.LeftButton) + # Wait until the worker thread is done + while app.worker_thread.isRunning(): QTest.qSleep(1000) + # The train functionality of the thread is tested with app tests + +def test_inference_button_click(qtbot, app): + # Click the "Generate Labels" button + app.sim = True + QTest.mouseClick(app.inference_button, Qt.LeftButton) + # Wait until the worker thread is done + while app.worker_thread.isRunning(): QTest.qSleep(1000) + #QTest.qWaitForWindowActive(app, timeout=5000) + # The inference functionality of the thread is tested with app tests + +def test_on_finished(qtbot, app): + # Assert that the on_finished function re-enabled the buttons and set the worker thread to None + assert app.train_button.isEnabled() + assert app.inference_button.isEnabled() + assert app.worker_thread is None + +def test_launch_napari_button_click_without_selection(qtbot, app): + # Try clicking the view button without having selected an image + app.sim = True + qtbot.mouseClick(app.launch_nap_button, Qt.LeftButton) + assert not hasattr(app, 'nap_win') + +def test_launch_napari_button_click(qtbot, app): + settings.accepted_types = setup_global_variable + # Simulate selection of an image to view before clivking on view button + index = app.list_view_eval.indexAt(app.list_view_eval.viewport().rect().topLeft()) + pos = app.list_view_eval.visualRect(index).center() + # Simulate file click + QTest.mouseClick(app.list_view_eval.viewport(), + Qt.LeftButton, + pos=pos) + app.on_item_eval_selected(index) + # Now click the view button + qtbot.mouseClick(app.launch_nap_button, Qt.LeftButton) + # Assert that the napari window has launched + assert hasattr(app, 'nap_win') + assert app.nap_win.isVisible() + + +@pytest.fixture(scope='session', autouse=True) +def cleanup_files(request): + # This code runs after all tests from all files have completed + yield + # Clean up + for fname in os.listdir('train_data_path'): + os.remove(os.path.join('train_data_path', fname)) + os.rmdir('train_data_path') + + for fname in os.listdir('in_prog'): + os.remove(os.path.join('in_prog', fname)) + os.rmdir('in_prog') + + for fname in os.listdir('eval_data_path'): + os.remove(os.path.join('eval_data_path', fname)) + os.rmdir('eval_data_path') diff --git a/src/client/test/test_mywidget.py b/src/client/test/test_mywidget.py new file mode 100644 index 0000000..e75172c --- /dev/null +++ b/src/client/test/test_mywidget.py @@ -0,0 +1,35 @@ +import pytest +import sys +sys.path.append('../') + +from PyQt5.QtWidgets import QMessageBox + +from dcp_client.gui._my_widget import MyWidget + +@pytest.fixture +def app(qtbot): + #q_app = QApplication([]) + widget = MyWidget() + qtbot.addWidget(widget) + yield widget + widget.close() + +def test_create_warning_box_ok(qtbot, app): + result = None + app.sim = True + def execute_warning_box(): + nonlocal result + box = QMessageBox() + result = app.create_warning_box("Test Message", custom_dialog=box) + qtbot.waitUntil(execute_warning_box, timeout=5000) + assert result is True + +def test_create_warning_box_cancel(qtbot, app): + result = None + app.sim = True + def execute_warning_box(): + nonlocal result + box = QMessageBox() + result = app.create_warning_box("Test Message", add_cancel_btn=True, custom_dialog=box) + qtbot.waitUntil(execute_warning_box, timeout=5000) # Add a timeout for the function to execute + assert result is False diff --git a/src/client/test/test_utils.py b/src/client/test/test_utils.py index 22e3b26..20144c6 100644 --- a/src/client/test/test_utils.py +++ b/src/client/test/test_utils.py @@ -1,8 +1,5 @@ import sys sys.path.append("../") -from qtpy.QtTest import QTest -from qtpy.QtWidgets import QMessageBox -from qtpy.QtCore import Qt, QTimer from dcp_client.utils import utils def test_get_relative_path(): @@ -25,22 +22,4 @@ def test_join_path(): filepath = '/here/we/are/testing/something.txt' path1 = '/here/we/are/testing' path2 = 'something.txt' - assert utils.join_path(path1, path2) == filepath - -def test_create_warning_box_ok(qtbot): - result = None - def execute_warning_box(): - nonlocal result - box = QMessageBox() - result = utils.create_warning_box("Test Message", custom_dialog=box, sim=True) - qtbot.waitUntil(execute_warning_box, timeout=5000) - assert result is True - -def test_create_warning_box_cancel(qtbot): - result = None - def execute_warning_box(): - nonlocal result - box = QMessageBox() - result = utils.create_warning_box("Test Message", add_cancel_btn=True, custom_dialog=box, sim=True) - qtbot.waitUntil(execute_warning_box, timeout=5000) # Add a timeout for the function to execute - assert result is False \ No newline at end of file + assert utils.join_path(path1, path2) == filepath \ No newline at end of file diff --git a/src/client/test/test_welcome_window.py b/src/client/test/test_welcome_window.py new file mode 100644 index 0000000..204a1cc --- /dev/null +++ b/src/client/test/test_welcome_window.py @@ -0,0 +1,107 @@ +import pytest +import sys +sys.path.append('../') + +from PyQt5.QtCore import Qt + +from dcp_client.gui.welcome_window import WelcomeWindow +from dcp_client.app import Application +from dcp_client.utils.bentoml_model import BentomlModel +from dcp_client.utils.fsimagestorage import FilesystemImageStorage +from dcp_client.utils.sync_src_dst import DataRSync +from dcp_client.utils import settings + +@pytest.fixture +def setup_global_variable(): + settings.accepted_types = (".jpg", ".jpeg", ".png", ".tiff", ".tif") + yield settings.accepted_types + +@pytest.fixture +def app(qtbot): + rsyncer = DataRSync(user_name="local", host_name="local", server_repo_path='.') + application = Application(BentomlModel(), rsyncer, FilesystemImageStorage(), "0.0.0.0", 7010) + # Create an instance of WelcomeWindow + #q_app = QApplication([]) + widget = WelcomeWindow(application) + qtbot.addWidget(widget) + yield widget + widget.close() + +def test_welcome_window_initialization(app): + assert app.title == "Select Dataset" + assert app.val_textbox.text() == "" + assert app.inprogr_textbox.text() == "" + assert app.train_textbox.text() == "" + +'''' +# TODO wait for github respose +def test_browse_eval_clicked(qtbot, app, monkeypatch): + # Mock the QFileDialog so that it immediately returns a directory + def handle_dialog(*args, **kwargs): + #if app.fd.isVisible(): + QCoreApplication.processEvents() + app.app.eval_data_path = '/path/to/selected/directory' + + #def mock_file_dialog(*args, **kwargs): + # return ['/path/to/selected/directory'] + + #monkeypatch.setattr(QFileDialog, 'getExistingDirectory', mock_file_dialog) + QTimer.singleShot(100, handle_dialog) + + #monkeypatch.setattr(app, 'browse_eval_clicked', mock_file_dialog) + #monkeypatch.setattr(QFileDialog, 'getExistingDirectory', mock_file_dialog) + # Simulate clicking the browse button for evaluation directory + qtbot.mouseClick(app.file_open_button_val, Qt.LeftButton, delay=1) + # Check if the textbox is updated with the selected path + assert app.val_textbox.text() == '/path/to/selected/directory' + +def test_browse_eval_clicked(qtbot, app): + # Simulate clicking the browse button for evaluation directory + qtbot.mouseClick(app.file_open_button_val, Qt.LeftButton) + # Check if the QFileDialog is shown + assert qtbot.waitUntil(lambda: hasattr(app, 'app.eval_data_path'), timeout=1000) + # Check if the textbox is updated with the selected path + assert app.val_textbox.text() == app.app.eval_data_path + + +def test_browse_train_clicked(qtbot, app): + # Simulate clicking the browse button for train directory + qtbot.mouseClick(app.file_open_button_train, Qt.LeftButton) + # Check if the QFileDialog is shown + assert qtbot.waitUntil(lambda: hasattr(app, 'app.train_data_path'), timeout=1000) + # Check if the textbox is updated with the selected path + assert app.train_textbox.text() == app.app.train_data_path + +def test_browse_inprogr_clicked(qtbot, app): + # Simulate clicking the browse button for in-progress directory + qtbot.mouseClick(app.file_open_button_prog, Qt.LeftButton) + # Check if the QFileDialog is shown + assert qtbot.waitUntil(lambda: hasattr(app, 'app.inprogr_data_path'), timeout=1000) + # Check if the textbox is updated with the selected path + assert app.inprogr_textbox.text() == app.app.inprogr_data_path + +''' +def test_start_main_not_selected(qtbot, app): + app.app.train_data_path = None + app.app.eval_data_path = None + app.sim = True + qtbot.mouseClick(app.start_button, Qt.LeftButton) + assert not hasattr(app, 'mw') + +def test_start_main(qtbot, app, setup_global_variable): + settings.accepted_types = setup_global_variable + # Set some paths for testing + app.app.eval_data_path = "/path/to/eval" + app.app.train_data_path = "/path/to/train" + # Simulate clicking the start button + qtbot.mouseClick(app.start_button, Qt.LeftButton) + # Check if the main window is created + #assert qtbot.waitUntil(lambda: hasattr(app, 'mw'), timeout=1000) + assert hasattr(app, 'mw') + # Check if the WelcomeWindow is hidden + assert app.isHidden() + +''' +def test_start_upload_and_main(qtbot, app, setup_global_variable): + # TODO +''' \ No newline at end of file