diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 90a92f7..8843605 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,7 +40,7 @@ jobs: git clone --depth 1 https://github.com/pyvista/gl-ci-helpers.git powershell gl-ci-helpers/appveyor/install_opengl.ps1 - - name: Install dependencies + - name: Install client dependencies run: | python -m pip install --upgrade pip python -m pip install setuptools @@ -52,6 +52,8 @@ jobs: - name: Install server dependencies (for communication tests) run: | + pip install numpy + pip install wheel pip install -e ".[dev]" working-directory: src/server @@ -87,7 +89,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Install dependencies + - name: Install server dependencies run: | python -m pip install --upgrade pip python -m pip install --upgrade setuptools diff --git a/src/client/dcp_client/gui/_custom_qt_helpers.py b/src/client/dcp_client/gui/_custom_qt_helpers.py new file mode 100644 index 0000000..a20276e --- /dev/null +++ b/src/client/dcp_client/gui/_custom_qt_helpers.py @@ -0,0 +1,64 @@ +from PyQt5.QtWidgets import QFileIconProvider, QStyledItemDelegate +from PyQt5.QtCore import QSize +from PyQt5.QtGui import QPixmap, QIcon + +from dcp_client.utils import settings + +class IconProvider(QFileIconProvider): + def __init__(self) -> None: + """Initializes the IconProvider with the default icon size.""" + super().__init__() + self.ICON_SIZE = QSize(512, 512) + + def icon(self, type: QFileIconProvider.IconType) -> QIcon: + """Returns the icon for the specified file type. + + :param type: The type of the file for which the icon is requested. + :type type: QFileIconProvider.IconType + :return: The icon for the file type. + :rtype: QIcon + """ + 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) + # a = a.scaled(QSize(1024, 1024)) + a.load(fn) + return QIcon(a) + else: + return super().icon(type) + + +class CustomItemDelegate(QStyledItemDelegate): + """ + A custom item delegate for setting a fixed height for items in a view. + This delegate overrides the sizeHint method to set a fixed height for items. + """ + def __init__(self, parent=None): + """ + Initialize the CustomItemDelegate. + + :param parent: The parent QObject. Default is None. + :type parent: QObject + """ + super().__init__(parent) + + def sizeHint(self, option, index): + """ + Returns the size hint for the item specified by the given index. + + :param option: The parameters used to draw the item. + :type option: QStyleOptionViewItem + + :param index: The model index of the item. + :type index: QModelIndex + + :returns: The size hint for the item. + :rtype: QSize + """ + size = super().sizeHint(option, index) + size.setHeight(100) + return size diff --git a/src/client/dcp_client/gui/_filesystem_wig.py b/src/client/dcp_client/gui/_filesystem_wig.py new file mode 100644 index 0000000..add73f9 --- /dev/null +++ b/src/client/dcp_client/gui/_filesystem_wig.py @@ -0,0 +1,112 @@ +import os +import numpy as np +from skimage.io import imread +from skimage.color import label2rgb + + +from PyQt5.QtWidgets import QFileSystemModel +from PyQt5.QtCore import Qt, QVariant, QDir +from PyQt5.QtGui import QImage + +from dcp_client.utils import settings + +class MyQFileSystemModel(QFileSystemModel): + def __init__(self, app): + """ + Initializes a custom QFileSystemModel + """ + super().__init__() + self.app = app + self.img_x = 100 + self.img_y = 100 + + def setFilter(self, filters): + """ + Sets filters for the model. + + :param filters: The filters to be applied. (QDir.Filters) + """ + filters |= QDir.NoDotAndDotDot | QDir.AllDirs | QDir.Files + super().setFilter(filters) + + # Exclude files containing '_seg' in their names + self.addFilter(lambda fileInfo: "_seg" not in fileInfo.fileName()) + + def addFilter(self, filterFunc): + """ + Adds a custom filter function to the model. + + :param filterFunc: The filter function to be added. (function) + """ + self.filterFunc = filterFunc + + def headerData(self, section, orientation, role): + """ + Reimplemented method to provide custom header data for the model's headers. + + :param section: The section (column) index. (int) + :param orientation: The orientation of the header. (Qt.Orientation) + :param role: The role of the header data. (int) + :rtype: QVariant + """ + if section == 0 and role == Qt.DisplayRole: + return "" + else: + return super().headerData(section, orientation, role) + + + def data(self, index, role=Qt.DisplayRole): + """ + Reimplemented method to provide custom data for the model's items. + + :param index: The index of the item. (QModelIndex) + :param role: The role of the data. (int) + :rtype: QVariant + """ + if not index.isValid(): + return QVariant() + + if role == Qt.DecorationRole: + + filepath_img = self.filePath(index) + # if an image of our dataset + if filepath_img.endswith(settings.accepted_types): + + # if a mask make sure it is displayed properly + if "_seg" in filepath_img and os.path.exists(filepath_img): + img = imread(filepath_img) + if img.ndim > 2: img = img[0] + img = label2rgb(img) + img = (255 * img.copy()).astype( + np.uint8 + )#np.transpose(img, (1, 0, 2)) + height, width = img.shape[0], img.shape[1] + img = QImage(img, + width, + height, + 3 * width, + QImage.Format_RGB888 + ) + #img = img.scaled(self.img_x, (width*self.img_x)//height, Qt.KeepAspectRatio) + img = img.scaled(self.img_x, self.img_y, Qt.KeepAspectRatio) # yields the same + else: + img = QImage(filepath_img).scaled(self.img_x, self.img_y, Qt.KeepAspectRatio) + ''' + # It would be cool if instead of the mask and the image we could show them both merged + # together with label2rgb if the mask exists - would need to remove _seg files from list + filepath_mask = '.'.join(filepath_img.split('.')[:-1])+'_seg.tiff' + if os.path.exists(filepath_mask): + mask = imread(filepath_mask) + if mask.ndim>2: mask = mask[0] + img = imread(filepath_img, as_gray=True) + img = label2rgb(mask, img) + img = QImage(img, + img.shape[1], + img.shape[0], + QImage.Format_RGB888 + ).scaled(self.img_x, self.img_y, Qt.KeepAspectRatio) + ''' + return img + + return super().data(index, role) + diff --git a/src/client/dcp_client/gui/main_window.py b/src/client/dcp_client/gui/main_window.py index c1eec89..f0813b8 100644 --- a/src/client/dcp_client/gui/main_window.py +++ b/src/client/dcp_client/gui/main_window.py @@ -1,24 +1,26 @@ from __future__ import annotations + from typing import TYPE_CHECKING -from PyQt5.QtWidgets import ( - QPushButton, - QVBoxLayout, - QFileSystemModel, - QHBoxLayout, - QLabel, - QTreeView, - QProgressBar, - QShortcut, +from qtpy.QtWidgets import ( + QPushButton, + QVBoxLayout, + QHBoxLayout, + QLabel, + QTreeView, + QProgressBar, + QShortcut, + QApplication ) -from PyQt5.QtCore import Qt, QThread, QModelIndex, pyqtSignal +from PyQt5.QtCore import Qt, QModelIndex, QThread, pyqtSignal, QSize from PyQt5.QtGui import QKeySequence -from dcp_client.utils import settings -from dcp_client.utils.utils import IconProvider - +from dcp_client.gui._custom_qt_helpers import IconProvider, CustomItemDelegate from dcp_client.gui.napari_window import NapariWindow from dcp_client.gui._my_widget import MyWidget +from dcp_client.gui._filesystem_wig import MyQFileSystemModel + +from dcp_client.utils import settings if TYPE_CHECKING: from dcp_client.app import Application @@ -77,10 +79,6 @@ class MainWindow(MyWidget): Opens the main window of the app where selected images in both directories are listed. User can view the images, train the model 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) -> None: @@ -94,33 +92,63 @@ def __init__(self, app: Application) -> None: :param app.train_data_path: Chosen path to images with labels, selected by the user in the WelcomeWindow. :type app.train_data_path: str """ + super().__init__() self.app = app - self.title = "Data Overview" + self.title = "DCP: Data Overview" self.worker_thread = None + self.accepted_types = ['*'+end for end in settings.accepted_types] self.main_window() def main_window(self) -> None: """Sets up the GUI""" self.setWindowTitle(self.title) - # self.resize(1000, 1500) + self.resize(1000, 700) + self.setStyleSheet("background-color: #f3f3f3;") + main_layout = QVBoxLayout() dir_layout = QHBoxLayout() + # create three boxes, for the three folder layouts self.uncurated_layout = QVBoxLayout() self.inprogress_layout = QVBoxLayout() self.curated_layout = QVBoxLayout() - + + # fill first box - uncurated layout self.eval_dir_layout = QVBoxLayout() self.eval_dir_layout.setContentsMargins(0, 0, 0, 0) + # add label self.label_eval = QLabel(self) - self.label_eval.setText("Uncurated dataset") + self.label_eval.setText("Uncurated Dataset") + self.label_eval.setMinimumHeight(50) + self.label_eval.setMinimumWidth(200) + self.label_eval.setAlignment(Qt.AlignCenter) + self.label_eval.setStyleSheet( + """ + font-size: 20px; + font-weight: bold; + background-color: #015998; + color: #ffffff; + border-radius: 5px; + padding: 8px 16px;""" + ) self.eval_dir_layout.addWidget(self.label_eval) # add eval dir list - model_eval = QFileSystemModel() + model_eval = MyQFileSystemModel(app=self.app) + model_eval.setNameFilters(self.accepted_types) + model_eval.setNameFilterDisables(False) # Enable the filters model_eval.setIconProvider(IconProvider()) + model_eval.sort(0, Qt.AscendingOrder) + self.list_view_eval = QTreeView(self) + self.list_view_eval.setToolTip("Select an image, click it, then press Enter") + self.list_view_eval.setIconSize(QSize(300, 300)) + self.list_view_eval.setStyleSheet("background-color: #ffffff") self.list_view_eval.setModel(model_eval) + + model_eval.setRootPath("/") + self.list_view_eval.setItemDelegate(CustomItemDelegate()) + for i in range(1, 4): self.list_view_eval.hideColumn(i) # self.list_view_eval.setFixedSize(600, 600) @@ -132,11 +160,21 @@ def main_window(self) -> None: self.eval_dir_layout.addWidget(self.list_view_eval) self.uncurated_layout.addLayout(self.eval_dir_layout) - # add buttons + # add run inference button self.inference_button = QPushButton("Generate Labels", self) - self.inference_button.clicked.connect( - self.on_run_inference_button_clicked - ) # add selected image + self.inference_button.setStyleSheet( + """QPushButton + { + background-color: #3d81d1; + font-size: 12px; + font-weight: bold; + color: #ffffff; + border-radius: 5px; + padding: 8px 16px; }""" + "QPushButton:hover { background-color: #7bc432; }" + "QPushButton:pressed { background-color: #7bc432; }" + ) + self.inference_button.clicked.connect(self.on_run_inference_button_clicked) self.uncurated_layout.addWidget(self.inference_button, alignment=Qt.AlignCenter) dir_layout.addLayout(self.uncurated_layout) @@ -144,15 +182,30 @@ def main_window(self) -> None: # In progress layout self.inprogr_dir_layout = QVBoxLayout() self.inprogr_dir_layout.setContentsMargins(0, 0, 0, 0) + # Add in progress layout self.label_inprogr = QLabel(self) + self.label_inprogr.setMinimumHeight(50) + self.label_inprogr.setMinimumWidth(200) + self.label_inprogr.setAlignment(Qt.AlignCenter) + self.label_inprogr.setStyleSheet( + "font-size: 20px; font-weight: bold; background-color: #015998; color: #ffffff; border-radius: 5px; padding: 8px 16px;" + ) self.label_inprogr.setText("Curation in progress") self.inprogr_dir_layout.addWidget(self.label_inprogr) # add in progress dir list - model_inprogr = QFileSystemModel() + model_inprogr = MyQFileSystemModel(app=self.app) + model_inprogr.setNameFilters(self.accepted_types) + model_inprogr.setNameFilterDisables(False) # Enable the filters # self.list_view = QListView(self) self.list_view_inprogr = QTreeView(self) + self.list_view_inprogr.setToolTip("Select an image, click it, then press Enter") + self.list_view_inprogr.setStyleSheet("background-color: #ffffff") model_inprogr.setIconProvider(IconProvider()) self.list_view_inprogr.setModel(model_inprogr) + + model_inprogr.setRootPath("/") + self.list_view_inprogr.setItemDelegate(CustomItemDelegate()) + for i in range(1, 4): self.list_view_inprogr.hideColumn(i) # self.list_view_inprogr.setFixedSize(600, 600) @@ -163,34 +216,46 @@ def main_window(self) -> None: self.inprogr_dir_layout.addWidget(self.list_view_inprogr) self.inprogress_layout.addLayout(self.inprogr_dir_layout) - self.launch_nap_button = QPushButton("View image and fix label", 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 + # the launch napari viewer button is currently hidden! + launch_nap_button = QPushButton() + launch_nap_button.setStyleSheet( + "QPushButton { background-color: transparent; border: none; border-radius: 5px; padding: 8px 16px; }" ) + + launch_nap_button.setEnabled(False) + self.inprogress_layout.addWidget(launch_nap_button, alignment=Qt.AlignCenter) + dir_layout.addLayout(self.inprogress_layout) # Create a shortcut for the Enter key to click the button enter_shortcut = QShortcut(QKeySequence(Qt.Key_Return), self) enter_shortcut.activated.connect(self.on_launch_napari_button_clicked) - dir_layout.addLayout(self.inprogress_layout) - # Curated layout self.train_dir_layout = QVBoxLayout() self.train_dir_layout.setContentsMargins(0, 0, 0, 0) self.label_train = QLabel(self) self.label_train.setText("Curated dataset") + self.label_train.setMinimumHeight(50) + self.label_train.setMinimumWidth(200) + self.label_train.setAlignment(Qt.AlignCenter) + self.label_train.setStyleSheet( + "font-size: 20px; font-weight: bold; background-color: #015998; color: #ffffff; border-radius: 5px; padding: 8px 16px;" + ) self.train_dir_layout.addWidget(self.label_train) # add train dir list - model_train = QFileSystemModel() + model_train = MyQFileSystemModel(app=self.app) + model_train.setNameFilters(self.accepted_types) + model_train.setNameFilterDisables(False) # Enable the filters # self.list_view = QListView(self) self.list_view_train = QTreeView(self) + self.list_view_train.setToolTip("Select an image, click it, then press Enter") + self.list_view_train.setStyleSheet("background-color: #ffffff") model_train.setIconProvider(IconProvider()) self.list_view_train.setModel(model_train) + model_train.setRootPath("/") + self.list_view_train.setItemDelegate(CustomItemDelegate()) + for i in range(1, 4): self.list_view_train.hideColumn(i) - # self.list_view_train.setFixedSize(600, 600) self.list_view_train.setRootIndex( model_train.setRootPath(self.app.train_data_path) ) @@ -198,23 +263,37 @@ def main_window(self) -> None: self.train_dir_layout.addWidget(self.list_view_train) self.curated_layout.addLayout(self.train_dir_layout) + # add train button self.train_button = QPushButton("Train Model", self) - self.train_button.clicked.connect( - self.on_train_button_clicked - ) # add selected image + self.train_button.setStyleSheet( + """QPushButton + { + background-color: #3d81d1; + font-size: 12px; + font-weight: bold; + color: #ffffff; + border-radius: 5px; + padding: 8px 16px; }""" + "QPushButton:hover { background-color: #7bc432; }" + "QPushButton:pressed { background-color: #7bc432; }" + ) + self.train_button.clicked.connect(self.on_train_button_clicked) self.curated_layout.addWidget(self.train_button, alignment=Qt.AlignCenter) - dir_layout.addLayout(self.curated_layout) + dir_layout.addLayout(self.curated_layout) main_layout.addLayout(dir_layout) # add progress bar progress_layout = QHBoxLayout() progress_layout.addStretch(1) self.progress_bar = QProgressBar(self) + self.progress_bar.setMinimumWidth(1000) + self.progress_bar.setAlignment(Qt.AlignCenter) self.progress_bar.setRange(0, 1) progress_layout.addWidget(self.progress_bar) main_layout.addLayout(progress_layout) + # add it all to main layout and show self.setLayout(main_layout) self.show() @@ -272,16 +351,20 @@ def on_run_inference_button_clicked(self) -> None: # start the worker thread to run inference self.worker_thread.start() - def on_launch_napari_button_clicked(self) -> None: - """ + 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 image, not a mask." + ''' + 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 visualize. 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() + try: + self.nap_win = NapariWindow(self.app) + self.nap_win.show() + except Exception as e: + message_text = f"An error occurred while opening the Napari window: {str(e)}" + _ = self.create_warning_box(message_text, message_title="Error") def on_finished(self, result: tuple) -> None: """ diff --git a/src/client/dcp_client/gui/napari_window.py b/src/client/dcp_client/gui/napari_window.py index 4d5fbe8..5739c32 100644 --- a/src/client/dcp_client/gui/napari_window.py +++ b/src/client/dcp_client/gui/napari_window.py @@ -31,8 +31,9 @@ def __init__(self, app: Application) -> None: super().__init__() self.app = app self.setWindowTitle("napari viewer") + self.setStyleSheet("background-color: #262930;") screen_size = QGuiApplication.primaryScreen().geometry() - self.resize(int(screen_size.width()*0.9), int(screen_size.height()*0.8)) + self.resize(int(screen_size.width()*0.8), int(screen_size.height()*0.8)) # Load image and get corresponding segmentation filenames img = self.app.load_image() @@ -95,7 +96,17 @@ def __init__(self, app: Application) -> None: if len(self.layer.data.shape) > 2: # User hint - message_label = QLabel("Choose an active mask") + message_label = QLabel('Choose an active mask') + message_label.setStyleSheet( + """ + font-size: 12px; + font-weight: bold; + background-color: #262930; + color: #D1D2D4; + border-radius: 5px; + padding: 8px 16px;""" + ) + message_label.setAlignment(Qt.AlignRight) layout.addWidget(message_label, 1, 0) @@ -110,7 +121,20 @@ def __init__(self, app: Application) -> None: # when user has chosen the mask, we don't want to change it anymore to avoid errors lock_button = QPushButton("Confirm Final Choice") - lock_button.setEnabled(False) + lock_button.setStyleSheet( + """QPushButton + { + background-color: #5A626C; + font-size: 12px; + font-weight: bold; + color: #D1D2D4; + border-radius: 5px; + padding: 8px 16px; }""" + "QPushButton:hover { background-color: #6A7380; }" + "QPushButton:pressed { background-color: #6A7380; }" + ) + + lock_button.setEnabled(True) lock_button.clicked.connect(self.set_editable_mask) layout.addWidget(lock_button, 1, 2) @@ -118,20 +142,86 @@ def __init__(self, app: Application) -> None: self.layer = None # add buttons for moving images to other dirs - add_to_inprogress_button = QPushButton( - "Move to 'Curatation in progress' folder" + add_to_inprogress_button = QPushButton('Move to \'Curatation in progress\' folder') + add_to_inprogress_button.setStyleSheet( + """QPushButton + { + background-color: #0064A8; + font-size: 12px; + font-weight: bold; + color: #D1D2D4; + border-radius: 5px; + padding: 8px 16px; }""" + "QPushButton:hover { background-color: #006FBA; }" + "QPushButton:pressed { background-color: #006FBA; }" + + ) layout.addWidget(add_to_inprogress_button, 2, 0, 1, 2) add_to_inprogress_button.clicked.connect( self.on_add_to_inprogress_button_clicked ) - add_to_curated_button = QPushButton("Move to 'Curated dataset' folder") + add_to_curated_button = QPushButton('Move to \'Curated dataset\' folder') + add_to_curated_button.setStyleSheet( + """QPushButton + { + background-color: #0064A8; + font-size: 12px; + font-weight: bold; + color: #D1D2D4; + border-radius: 5px; + padding: 8px 16px; }""" + "QPushButton:hover { background-color: #006FBA; }" + "QPushButton:pressed { background-color: #006FBA; }" + + ) + layout.addWidget(add_to_curated_button, 2, 2, 1, 2) add_to_curated_button.clicked.connect(self.on_add_to_curated_button_clicked) self.setLayout(layout) + remove_from_dataset_button = QPushButton('Remove from dataset') + remove_from_dataset_button.setStyleSheet( + """QPushButton + { + background-color: #0064A8; + font-size: 12px; + font-weight: bold; + color: #D1D2D4; + border-radius: 5px; + padding: 8px 16px; }""" + "QPushButton:hover { background-color: #006FBA; }" + "QPushButton:pressed { background-color: #006FBA; }" + + ) + layout.addWidget(remove_from_dataset_button, 3, 0, 1, 4) + remove_from_dataset_button.clicked.connect(self.on_remove_from_dataset_button_clicked) + + def on_remove_from_dataset_button_clicked(self) -> None: + """ + Defines what happens when the "Remove from dataset" button is clicked. + """ + ''' + try: + # get image name + files_to_remove = [self.viewer.layers.selection.active.name] + except AttributeError: + message_text = "Please first select the image in the layer list." + _ = self.create_warning_box(message_text, message_title="Warning") + return + ''' + rmv_files = [self.app.cur_selected_img] + self.app.search_segs() + rmv_files.extend(self.app.seg_filepaths) + # Delete the image and corresponding masks from the dataset + self.app.delete_images(rmv_files) + self.app.cur_selected_img = "" + self.app.seg_filepaths = [] + self.viewer.close() + self.close() + def set_editable_mask(self) -> None: """ This function is not implemented. In theory the use can choose between which mask to edit. @@ -160,6 +250,9 @@ def axis_changed(self, event) -> None: Is triggered each time the user switches the viewer between the mask channels. At this point the class mask needs to be updated according to the changes made tot the instance segmentation mask. """ + + if self.app.cur_selected_img=="": return # because this also gets triggered when removing outlier + self.active_mask_index = self.viewer.dims.current_step[0] masks = deepcopy(self.layer.data) diff --git a/src/client/dcp_client/gui/welcome_window.py b/src/client/dcp_client/gui/welcome_window.py index f4bd73d..a5e5a07 100644 --- a/src/client/dcp_client/gui/welcome_window.py +++ b/src/client/dcp_client/gui/welcome_window.py @@ -9,7 +9,8 @@ QFileDialog, QLineEdit, ) -from qtpy.QtCore import Qt +from qtpy.QtCore import Qt, QEvent + from dcp_client.gui.main_window import MainWindow from dcp_client.gui._my_widget import MyWidget @@ -33,31 +34,66 @@ def __init__(self, app: Application) -> None: """ super().__init__() self.app = app - self.resize(200, 200) - self.title = "Select Dataset" + self.setWindowTitle("DCP") + self.setStyleSheet("background-color: #f3f3f3;") + self.resize(590, 250) + self.main_layout = QVBoxLayout() - input_layout = QHBoxLayout() - label = QLabel(self) - label.setText( - "Welcome to Helmholtz AI data centric tool! Please select your dataset folder" + + title_label = QLabel("Welcome to the Helmholtz AI Data-Centric Tool!") + title_label.setAlignment(Qt.AlignCenter) + title_label.setStyleSheet( + "font-size: 20px; font-weight: bold; color: #015998;" ) - self.main_layout.addWidget(label) + self.main_layout.addWidget(title_label) + instructions_label = QLabel("Please select your dataset folders:") + instructions_label.setAlignment(Qt.AlignLeft)# AlignCenter) + instructions_label.setStyleSheet( + "font-size: 14px; color: #000000;" + ) + self.main_layout.addWidget(instructions_label) + + + input_layout = QHBoxLayout() + self.text_layout = QVBoxLayout() self.path_layout = QVBoxLayout() self.button_layout = QVBoxLayout() val_label = QLabel(self) - val_label.setText("Uncurated dataset path:") + val_label.setText('Uncurated dataset:') + + inprogr_label = QLabel(self) - inprogr_label.setText("Curation in progress path:") + inprogr_label.setText("In progress directory:") train_label = QLabel(self) - train_label.setText("Curated dataset path:") + train_label.setText('Curated dataset:') + + self.text_layout.addWidget(val_label) self.text_layout.addWidget(inprogr_label) self.text_layout.addWidget(train_label) self.val_textbox = QLineEdit(self) + self.val_textbox.setPlaceholderText("Double-click to browse") + # self.val_textbox.setToolTip("Double-click to browse") + + self.val_textbox.textEdited.connect(lambda x: self.on_text_changed(self.val_textbox, "eval", x)) + self.val_textbox.installEventFilter(self) + + self.inprogr_textbox = QLineEdit(self) + self.inprogr_textbox.setPlaceholderText("Double-click to browse") + # self.inprogr_textbox.setToolTip("Double-click to browse") + self.inprogr_textbox.textEdited.connect(lambda x: self.on_text_changed(self.inprogr_textbox, "inprogress", x)) + self.inprogr_textbox.installEventFilter(self) + + self.train_textbox = QLineEdit(self) + self.train_textbox.setPlaceholderText("Double-click to browse") + # self.train_textbox.setToolTip("Double-click to browse") + self.train_textbox.textEdited.connect(lambda x: self.on_text_changed(self.train_textbox, "train", x)) + self.train_textbox.installEventFilter(self) + ''' self.val_textbox.textEdited.connect( lambda x: self.on_text_changed(self.val_textbox, "eval", x) ) @@ -71,24 +107,66 @@ def __init__(self, app: Application) -> None: self.train_textbox.textEdited.connect( lambda x: self.on_text_changed(self.train_textbox, "train", x) ) + ''' self.path_layout.addWidget(self.val_textbox) self.path_layout.addWidget(self.inprogr_textbox) self.path_layout.addWidget(self.train_textbox) - + ''' self.file_open_button_val = QPushButton("Browse", self) + self.file_open_button_val.setFixedSize(80, 30) + self.file_open_button_val.setStyleSheet( + """QPushButton + { + background-color: #3d81d1; + font-size: 11px; + font-weight: bold; + color: #ffffff; + border-radius: 5px; + padding: 8px 16px; }""" + "QPushButton:hover { background-color: #006FBA; }" + ) 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.setFixedSize(80, 30) + self.file_open_button_prog.setStyleSheet( + """QPushButton + { + background-color: #3d81d1; + font-size: 11px; + font-weight: bold; + color: #ffffff; + border-radius: 5px; + padding: 8px 16px; }""" + "QPushButton:hover { background-color: #006FBA; }" + ) 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.setFixedSize(80, 30) + self.file_open_button_train.setStyleSheet( + """QPushButton + { + background-color: #3d81d1; + font-size: 11px; + font-weight: bold; + color: #ffffff; + border-radius: 5px; + padding: 8px 16px; }""" + "QPushButton:hover { background-color: #006FBA; }" + + ) 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) input_layout.addLayout(self.button_layout) @@ -96,6 +174,18 @@ def __init__(self, app: Application) -> None: self.start_button = QPushButton("Start", self) self.start_button.setFixedSize(120, 30) + self.start_button.setStyleSheet( + """QPushButton + { + background-color: #3d81d1; + font-size: 12px; + font-weight: bold; + color: #ffffff; + border-radius: 5px; + padding: 8px 16px; }""" + "QPushButton:hover { background-color: #7bc432; }" + + ) self.start_button.show() # check if we need to upload data to server self.done_upload = False # we only do once @@ -152,10 +242,21 @@ def on_text_changed(self, field_obj: QLineEdit, field_name: str, text: str) -> N elif field_name == "inprogress": self.app.inprogr_data_path = text field_obj.setText(text) + + def eventFilter(self, obj, event): + ''' Event filter to capture double-click events on QLineEdit widgets ''' + if event.type() == QEvent.MouseButtonDblClick: + if obj == self.val_textbox: + self.browse_eval_clicked() + elif obj == self.inprogr_textbox: + self.browse_inprogr_clicked() + elif obj == self.train_textbox: + self.browse_train_clicked() + return super().eventFilter(obj, event) + - def browse_inprogr_clicked(self) -> None: - """ - Activates when the user clicks the button to choose the curation in progress directory (QFileDialog) and + def browse_inprogr_clicked(self): + """ Activates when the user clicks the button to choose the curation in progress directory (QFileDialog) and displays the name of the evaluation directory chosen in the validation textbox line (QLineEdit). """ diff --git a/src/client/dcp_client/utils/utils.py b/src/client/dcp_client/utils/utils.py index eb08f88..20232e9 100644 --- a/src/client/dcp_client/utils/utils.py +++ b/src/client/dcp_client/utils/utils.py @@ -1,41 +1,7 @@ -from qtpy.QtWidgets import QFileIconProvider -from qtpy.QtCore import QSize -from qtpy.QtGui import QPixmap, QIcon - from pathlib import Path, PurePath import yaml import numpy as np -from dcp_client.utils import settings - - -class IconProvider(QFileIconProvider): - def __init__(self) -> None: - """Initializes the IconProvider with the default icon size.""" - super().__init__() - self.ICON_SIZE = QSize(512, 512) - - def icon(self, type: QFileIconProvider.IconType) -> QIcon: - """Returns the icon for the specified file type. - - :param type: The type of the file for which the icon is requested. - :type type: QFileIconProvider.IconType - :return: The icon for the file type. - :rtype: QIcon - """ - 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) - a.load(fn) - return QIcon(a) - else: - return super().icon(type) - - def read_config(name: str, config_path: str = "config.yaml") -> dict: """Reads the configuration file @@ -76,7 +42,6 @@ def get_path_stem(filepath: str) -> str: """ return str(Path(filepath).stem) - def get_path_name(filepath: str) -> str: """Returns the name of the file from the given filepath. diff --git a/src/client/setup.py b/src/client/setup.py index 7303233..99d76d6 100644 --- a/src/client/setup.py +++ b/src/client/setup.py @@ -22,6 +22,7 @@ "torchvision", "napari-sam @ git+https://github.com/christinab12/napari-sam.git@main", "bentoml[grpc]>=1.2.5", + "opencv", ], extras_require={ "dev": [ diff --git a/src/client/test/test_main_window.py b/src/client/test/test_main_window.py index 788dea3..16e71f6 100644 --- a/src/client/test/test_main_window.py +++ b/src/client/test/test_main_window.py @@ -17,6 +17,7 @@ from dcp_client.utils.sync_src_dst import DataRSync from dcp_client.utils import settings +from unittest.mock import MagicMock @pytest.fixture() def setup_global_variable(): @@ -65,7 +66,7 @@ def app(qtbot, setup_global_variable): def test_main_window_setup(qtbot, app, setup_global_variable): settings.accepted_types = setup_global_variable - assert app.title == "Data Overview" + assert app.title == "DCP: Data Overview" def test_item_train_selected(qtbot, app, setup_global_variable): @@ -140,16 +141,31 @@ def test_on_finished(qtbot, app): assert app.train_button.isEnabled() assert app.inference_button.isEnabled() assert app.worker_thread is None - - + +''' +# what is the intended use case here? How would an exception be triggered? + +def test_launch_napari_button_clicked_with_selected_img(qtbot, app): + + with pytest.raises(Exception) as exc_info: + index = app.list_view_eval.indexAt(app.list_view_eval.viewport().rect().topLeft()) + app.on_item_eval_selected(index) + #window = MainWindow(app.app) + app.nap_win = MagicMock(side_effect=Exception("Test exception")) + app.on_launch_napari_button_clicked() + + assert "An error occurred while opening the Napari window" in str(exc_info.value) +''' + +''' 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): +# removing this test as launch_nap_button does not exist anymore! +def test_launch_napari_button_click(qtbot, app, setup_global_variable): settings.accepted_types = setup_global_variable # Simulate selection of an image to view before clicking on view button index = app.list_view_eval.indexAt(app.list_view_eval.viewport().rect().topLeft()) @@ -157,11 +173,13 @@ def test_launch_napari_button_click(qtbot, app): # Simulate file click QTest.mouseClick(app.list_view_eval.viewport(), Qt.LeftButton, pos=pos) app.on_item_eval_selected(index) + print('hhhhhh', app.app.cur_selected_img) # 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) diff --git a/src/client/test/test_welcome_window.py b/src/client/test/test_welcome_window.py index 9fdaa49..1a7226a 100644 --- a/src/client/test/test_welcome_window.py +++ b/src/client/test/test_welcome_window.py @@ -49,7 +49,7 @@ def app_remote(qtbot): def test_welcome_window_initialization(app): - assert app.title == "Select Dataset" + assert app.windowTitle() == "DCP" assert app.val_textbox.text() == "" assert app.inprogr_textbox.text() == "" assert app.train_textbox.text() == "" diff --git a/src/server/dcp_server/service.py b/src/server/dcp_server/service.py index 16f7e3b..e0cae98 100644 --- a/src/server/dcp_server/service.py +++ b/src/server/dcp_server/service.py @@ -12,13 +12,14 @@ segmentation_module = __import__("segmentationclasses") # Import configuration -rel_path = os.path.dirname(os.path.realpath(__file__)) -service_config = read_config("service", config_path=os.path.join(rel_path, "config.yaml")) -model_config = read_config("model", config_path=os.path.join(rel_path, "config.yaml")) -data_config = read_config("data", config_path=os.path.join(rel_path, "config.yaml")) -train_config = read_config("train", config_path=os.path.join(rel_path, "config.yaml")) -eval_config = read_config("eval", config_path=os.path.join(rel_path, "config.yaml")) -setup_config = read_config("setup", config_path=os.path.join(rel_path, "config.yaml")) +script_path = os.path.abspath(__file__) +config_path = os.path.join(os.path.dirname(script_path), "config.yaml") +service_config = read_config("service", config_path=config_path) +model_config = read_config("model", config_path=config_path) +data_config = read_config("data", config_path) +train_config = read_config("train", config_path) +eval_config = read_config("eval", config_path) +setup_config = read_config("setup", config_path) # instantiate the model