From 435b0bc08b17a35666c64a46a9b2ac9a832d2779 Mon Sep 17 00:00:00 2001 From: ias49 Date: Sat, 15 Jul 2023 15:32:02 +0100 Subject: [PATCH] updated to allow for radiobuttons and numerous bug fixes --- README.md | 31 +- setup.py | 7 +- setup_86x64.py | 5 +- setup_arm64.py | 5 +- speedy_qc/config.yml | 37 +- speedy_qc/config_chexpert.yml | 21 + speedy_qc/config_quality.yml | 35 + speedy_qc/config_wizard.py | 344 ---------- speedy_qc/graphics.py | 236 +++++++ speedy_qc/main.py | 61 +- speedy_qc/{main_window.py => main_app.py} | 643 +++++++++---------- speedy_qc/utils.py | 147 +++-- speedy_qc/{custom_windows.py => windows.py} | 203 ++++-- speedy_qc/wizard.py | 676 ++++++++++++++++++++ 14 files changed, 1611 insertions(+), 840 deletions(-) create mode 100644 speedy_qc/config_chexpert.yml create mode 100644 speedy_qc/config_quality.yml delete mode 100644 speedy_qc/config_wizard.py create mode 100644 speedy_qc/graphics.py rename speedy_qc/{main_window.py => main_app.py} (68%) rename speedy_qc/{custom_windows.py => windows.py} (80%) create mode 100644 speedy_qc/wizard.py diff --git a/README.md b/README.md index 8e398ff..2154d2c 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,21 @@ Primarily for use on Mac OS X, but should work on Linux and Windows. > **Warning:** Please note that this application is still in development and there may be unresolved bugs and issues. Use at your own risk! +## Table of Contents +- [Installation](#installation) +- [Usage](#usage) + - [Inputs and Outputs](#inputs-and-outputs) + - [Checkboxes](#checkboxes) + - [Bounding Boxes](#bounding-boxes) + - [Radiobuttons](#radiobuttons) + - [Progress](#progress) + - [Keyboard Shortcuts](#keyboard-shortcuts) +- [Customisation](#customisation) + - [Configuration Wizard](#configuration-wizard) + - [YAML File](#yaml-file) +- [Backup Files](#backup-files) +- [Executable Application](#executable-application) + Installation ------------ @@ -45,9 +60,9 @@ speedy_qc Alternatively, the app may be run from an executable (see below). -### Outputs +### Inputs and Outputs -Outputs are stored in a .json file in a directory chosen by the user. +#### Checkboxes Checkboxes are stored as integers: @@ -57,7 +72,7 @@ Checkboxes are stored as integers: | 1 | Uncertain | | 2 | True / Yes | -### Bounding Boxes +#### Bounding Boxes - Added to the image by clicking and dragging the mouse. - Multiple boxes may be added to each image and for each finding. @@ -65,12 +80,17 @@ Checkboxes are stored as integers: - Moved by clicking and dragging the box. - Deleted by right-clicking on the box and selecting `Remove` from the menu. +#### Radiobuttons + +Radiobuttons are stored as integers with the meaning of the integer corresponding to the order of the radiobuttons +inputted in the configuration wizard. For example, if the radiobuttons are `['Normal', 'Abnormal']`, then the values +will be `0` for `Normal` and `1` for `Abnormal`. + ### Progress Your progress through the folder of images is shown in the progress bar at the bottom of the window. -Keyboard Shortcuts ------------------- +### Keyboard Shortcuts | Key | Action | |:---------------------------------------------:|:------------------:| @@ -94,6 +114,7 @@ Customisation The program can be customised to suit the user's needs. The following options are available: - Select which checkboxes are required - Select whether checkboxes can be set to 'uncertain' (i.e. 1 - see above) +- Select whether radiobuttons are required - Change the maximum number of backups - Backup frequency in minutes - Change the backup directory diff --git a/setup.py b/setup.py index 5b09642..e352a52 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( author='Ian Selby', author_email='ias49@cam.ac.uk', - description='Tool to label single DICOM images using custom checkboxes', + description='Tool to label single DICOM images using custom checkboxes, bounding boxes and radiobuttons', name='Speedy QC', url='https://github.com/selbs/speedy_qc', use_scm_version=True, @@ -13,7 +13,7 @@ entry_points={ 'console_scripts': [ 'speedy_qc=speedy_qc.main:main', - 'speedy_config=speedy_qc.config_wizard:main' + 'speedy_config=speedy_qc.wizard:main' ] }, classifiers=[ @@ -36,6 +36,7 @@ "setuptools>=42.0.0", "python-gdcm>=3.0.21", "py2app>=0.28.5", - "matplotlib>=3.4.3" + "matplotlib>=3.4.3", + "imageio>=2.31.0", ], ) diff --git a/setup_86x64.py b/setup_86x64.py index 53d64fc..add5368 100644 --- a/setup_86x64.py +++ b/setup_86x64.py @@ -23,7 +23,7 @@ entry_points={ 'console_scripts': [ 'speedy_qc=speedy_qc.main:main', - 'speedy_config=speedy_qc.config_wizard:main' + 'speedy_config=speedy_qc.wizard:main' ] }, classifiers=[ @@ -48,6 +48,7 @@ "qimage2ndarray>=1.10.0", "qt-material>=2.14", "QtAwesome>=1.2.3", - "matplotlib>=3.4.3" + "matplotlib>=3.4.3", + "imageio>=2.31.0", ], ) diff --git a/setup_arm64.py b/setup_arm64.py index 7a11fd0..3fab540 100644 --- a/setup_arm64.py +++ b/setup_arm64.py @@ -25,7 +25,7 @@ entry_points={ 'console_scripts': [ 'speedy_qc=speedy_qc.main:main', - 'speedy_config=speedy_qc.config_wizard:main' + 'speedy_config=speedy_qc.wizard:main' ] }, classifiers=[ @@ -50,6 +50,7 @@ "qimage2ndarray>=1.10.0", "qt-material>=2.14", "QtAwesome>=1.2.3", - "matplotlib>=3.4.3" + "matplotlib>=3.4.3", + "imageio>=2.31.0", ], ) diff --git a/speedy_qc/config.yml b/speedy_qc/config.yml index 775b536..f9af3e8 100644 --- a/speedy_qc/config.yml +++ b/speedy_qc/config.yml @@ -1,21 +1,18 @@ -backup_dir: ~/speedy_qc/backups +backup_dir: /Users/ianselby/speedy_qc/backups +backup_interval: 5 checkboxes: -- No Finding -- Enlarged Cardiomediastinum -- Cardiomegaly -- Lung Lesion (Mass/Nodule) -- Airspace Opacity -- Edema -- Consolidation -- Pneumonia -- Atelectasis / Collapse -- Pneumothorax / Pneumomediast. -- Pleural Effusion -- Pleural Other -- Fracture -- Support Devices -- Other Finding -log_dir: ~/speedy_qc/logs -max_backups: 20 -backup_interval: 2 -tristate_cboxes: true +- QC1 +- QC2 +- QC3 +- QC4 +- QC5 +log_dir: /Users/ianselby/speedy_qc/logs +max_backups: 10 +radiobuttons: +- labels: + - 1 + - 2 + - 3 + - 4 + title: Radiobuttons +tristate_checkboxes: true diff --git a/speedy_qc/config_chexpert.yml b/speedy_qc/config_chexpert.yml new file mode 100644 index 0000000..16fa3b9 --- /dev/null +++ b/speedy_qc/config_chexpert.yml @@ -0,0 +1,21 @@ +backup_dir: /Users/ianselby/speedy_qc/backups +backup_interval: 5 +checkboxes: +- No Finding +- Enlarged Cardiomediastinum +- Cardiomegaly +- Lung Lesion (Mass/Nodule) +- Airspace Opacity +- Edema +- Consolidation +- Pneumonia +- Atelectasis / Collapse +- Pneumothorax / Pneumomediast. +- Pleural Effusion +- Pleural Other +- Fracture +- Support Devices +- Other Finding +log_dir: /Users/ianselby/speedy_qc/logs +max_backups: 10 +tristate_checkboxes: true diff --git a/speedy_qc/config_quality.yml b/speedy_qc/config_quality.yml new file mode 100644 index 0000000..944191f --- /dev/null +++ b/speedy_qc/config_quality.yml @@ -0,0 +1,35 @@ +backup_dir: /Users/ianselby/speedy_qc/backups +backup_interval: 5 +log_dir: /Users/ianselby/speedy_qc/logs +max_backups: 10 +radiobuttons: +- labels: + - Very poor + - Poor + - Good + - Very good + title: Overall Quality +- labels: + - Underexposed + - Good + - Overexposed + title: Exposure +- labels: + - Very low + - Low + - Good + - High + title: Contrast +- labels: + - None + - Mild + - Moderate + - Severe + title: Blur +- labels: + - None + - Mild + - Moderate + - Severe + title: Artefacts +tristate_checkboxes: true diff --git a/speedy_qc/config_wizard.py b/speedy_qc/config_wizard.py deleted file mode 100644 index 8a144ac..0000000 --- a/speedy_qc/config_wizard.py +++ /dev/null @@ -1,344 +0,0 @@ -""" -config_wizard.py - -This module provides a configuration wizard for the Speedy QC application, allowing users -to customize various settings such as checkbox labels, maximum number of backup files, -and directories for backup and log files. The wizard can be run from the initial dialog -box of the application, from the command line, or from Python. - -Classes: - - ConfigurationWizard: A QWizard class implementation to guide users through the process of - customizing the application configuration. -""" - -from PyQt6.QtCore import * -from PyQt6.QtGui import * -from PyQt6.QtWidgets import * -import yaml -import os -from qt_material import apply_stylesheet, get_theme -import sys -import pkg_resources - -from speedy_qc.utils import open_yml_file, setup_logging, ConnectionManager - -if hasattr(sys, '_MEIPASS'): - # This is a py2app executable - resource_dir = sys._MEIPASS -elif 'main.py' in os.listdir(os.path.dirname(os.path.abspath("__main__"))): - # This is a regular Python script - resource_dir = os.path.dirname(os.path.abspath("__main__")) -else: - resource_dir = os.path.join(os.path.dirname(os.path.abspath("__main__")), 'speedy_qc') - -class ConfigurationWizard(QWizard): - """ - A QWizard implementation for customizing the configuration of the Speedy QC application. - Allows users to customize checkbox labels, maximum number of backup files, and directories - for backup and log files. Can be run from the initial dialog box, from the command line, - or from Python. - - Methods: - - create_label_page: Creates the first page of the wizard, allowing users to customize - the labels of the checkboxes. - - create_backup_page: Creates the second page of the wizard, allowing users to customize - the maximum number of backup files and the directories for backup - and log files. - - add_label: Adds a new label to the label page for a new checkbox/finding. - - create_save_page: Creates the third page of the wizard, allowing users to save the - configuration to a .yml file. - - update_combobox_stylesheet: Updates the stylesheet of the QComboBoxes in the label page - to make the options more visible. - - update_combobox_state: Updates the QComboBox on the save page with the list of existing .yml files. - - accept: Saves the configuration to a .yml file and closes the wizard. - """ - def __init__(self, config_path: str): - super().__init__() - self.settings = QSettings('SpeedyQC', 'DicomViewer') - self.connection_manager = ConnectionManager() - - self.setStyleSheet(f""" - QLineEdit {{ - color: {get_theme('dark_blue.xml')['primaryLightColor']}; - }} - QSpinBox {{ - color: {get_theme('dark_blue.xml')['primaryLightColor']}; - }} - QComboBox {{ - color: {get_theme('dark_blue.xml')['primaryLightColor']}; - }} - """) - - # Set the wizard style to have the title and icon at the top - self.setWizardStyle(QWizard.WizardStyle.ModernStyle) - - self.config_path = config_path - self.config_data = None - - # Enable IndependentPages option - self.setOption(QWizard.WizardOption.IndependentPages, True) - - # Set the logo pixmap - - icon_path = os.path.join(resource_dir, 'assets/3x/white_panel@3x.png') - pixmap = QPixmap(icon_path) - self.setPixmap(QWizard.WizardPixmap.LogoPixmap, pixmap.scaled(320, 320, Qt.AspectRatioMode.KeepAspectRatio)) - - # Load the config file - self.config_data = open_yml_file(self.config_path) - self.checkboxes = self.config_data.get('checkboxes', []) - self.max_backups = self.config_data.get('max_backups', 10) - self.backup_interval = self.config_data.get('backup_interval', 5) - self.backup_dir = self.config_data.get('backup_dir', os.path.expanduser('~/speedy_qc/backups')) - self.log_dir = self.config_data.get('log_dir', os.path.expanduser('~/speedy_qc/logs')) - self.tristate_cboxes = self.config_data.get('tristate_checkboxes', False) - - # Create pages for the wizard - self.label_page = self.create_label_page() - self.backup_page = self.create_backup_page() - self.save_page = self.create_save_page() - - # Set up the wizard - self.addPage(self.label_page) - self.addPage(self.backup_page) - self.addPage(self.save_page) - - # Set the window title and modality - self.setWindowTitle("Speedy QC Configuration Wizard") - self.setWindowModality(Qt.WindowModality.ApplicationModal) - - # Set the size of the wizard to allow for list of checkboxes to fit nicely - self.resize(700, 800) - - # Set the default button to be the next / finish button - next_button = self.button(QWizard.NextButton) - next_button.setDefault(True) - - def create_label_page(self): - """ - Creates the page for the wizard to customize the labels for the checkboxes. - - Returns: - QWizardPage: The QWizardPage containing the UI elements for customizing checkbox labels. - """ - page = QWizardPage() - page.setTitle("Checkbox Labels") - page.setSubTitle("\nPlease name the checkboxes to label the images...\n") - - # Create a vertical layout for the page - layout = QVBoxLayout(page) - - cbox_layout = QHBoxLayout() - self.tristate_checkbox = QCheckBox("Use tri-state checkboxes, i.e. have third uncertain option") - self.tristate_checkbox.setChecked(bool(self.config_data.get('tristate_checkboxes', False))) - self.connection_manager.connect(self.tristate_checkbox.stateChanged, self.update_tristate_checkboxes_state) - cbox_layout.addWidget(self.tristate_checkbox) - cbox_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter) - cbox_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - layout.addLayout(cbox_layout) - - # Create a widget for the checkbox labels - self.labels_widget = QWidget(page) - self.labels_layout = QVBoxLayout(self.labels_widget) - - for label in self.checkboxes: - line_edit = QLineEdit(label) - self.labels_layout.addWidget(line_edit) - - self.add_label_button = QPushButton("Add Label") - self.add_label_button.clicked.connect(self.add_label) - - layout.addWidget(self.labels_widget) - layout.addWidget(self.add_label_button) - - return page - - def update_tristate_checkboxes_state(self, state): - """ - Updates the state of the tristate_checkboxes option in the config file. - """ - self.config_data['tristate_checkboxes'] = state - - def create_backup_page(self): - """ - Creates the page for the wizard to customise the backup and log directories, and - the maximum number of backup files. - """ - - page = QWizardPage() - page.setTitle("Logging and Backup Files") - page.setSubTitle("\nPlease choose where logs and backups should be stored, and\n" - "specify maximum number of backup files...\n") - - # Create a vertical layout for the page - layout = QVBoxLayout(page) - - - self.backup_widget = QWidget(page) - self.backup_layout = QVBoxLayout(self.backup_widget) - - # Create a widget for the log directory - log_dir_label = QLabel("Log Directory:") - self.log_dir_edit = QLineEdit() - self.log_dir_edit.setText(self.settings.value("log_dir", os.path.expanduser(self.log_dir))) - self.backup_layout.addWidget(log_dir_label) - self.backup_layout.addWidget(self.log_dir_edit) - - spacer = QSpacerItem(0, 40, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) - self.backup_layout.addItem(spacer) - - backup_dir_label = QLabel("Backup Directory:") - self.backup_dir_edit = QLineEdit() - self.backup_dir_edit.setText(self.settings.value("backup_dir", os.path.expanduser(self.backup_dir))) - self.backup_layout.addWidget(backup_dir_label) - self.backup_layout.addWidget(self.backup_dir_edit) - - # Create a widget for the maximum number of backups - self.backup_spinbox = QSpinBox() - self.backup_spinbox.setRange(1, 100) - self.backup_spinbox.setValue(self.max_backups) - - self.backup_layout.addWidget(QLabel("Maximum Number of Backups:")) - self.backup_layout.addWidget(self.backup_spinbox) - - self.backup_int_spinbox = QSpinBox() - self.backup_int_spinbox.setRange(1, 30) - self.backup_int_spinbox.setValue(self.backup_interval) - - self.backup_layout.addWidget(QLabel("Backup Interval (mins):")) - self.backup_layout.addWidget(self.backup_int_spinbox) - - layout.addWidget(self.backup_widget) - - return page - - def add_label(self): - """ - Adds a new label to the list of labels to add additional checkboxes. - """ - line_edit = QLineEdit() - self.labels_layout.addWidget(line_edit) - - def create_save_page(self): - """ - Creates the page for the wizard to save the configuration file. Allows the user to - select/overwrite an existing configuration file or enter a new filename. - - Returns: - QWizardPage: The QWizardPage containing the UI elements for saving the configuration file. - """ - page = QWizardPage() - page.setTitle("Save Configuration") - page.setSubTitle("\nPlease select an existing configuration file or enter a new filename...\n") - - # Create a vertical layout for the page - layout = QVBoxLayout(page) - - # Create QComboBox for the list of available .yml files - self.config_files_combobox = QComboBox() - for file in os.listdir(resource_dir): - if file.endswith('.yml'): - self.config_files_combobox.addItem(file) - - layout.addWidget(QLabel("Existing Configuration Files:")) - layout.addWidget(self.config_files_combobox) - - # Create QLineEdit for the filename - self.filename_edit = QLineEdit() - self.filename_edit.setPlaceholderText("config.yml") - self.filename_edit.textChanged.connect(self.update_config_combobox_state) # Connect the textChanged signal - layout.addWidget(QLabel("New Filename (Optional):")) - layout.addWidget(self.filename_edit) - - # Display the save path - layout.addWidget(QLabel("Save directory:")) - save_dir_label = QLabel(resource_dir) - layout.addWidget(save_dir_label) - - return page - - def update_config_combobox_state(self): - """ - Updates the QComboBox on the save page with the list of existing .yml files. - """ - if self.filename_edit.text(): - self.config_files_combobox.setEnabled(False) - else: - self.config_files_combobox.setEnabled(True) - self.update_combobox_stylesheet() - - def update_combobox_stylesheet(self): - """ - Updates the stylesheet of the QComboBox on the save page to indicate whether it is - enabled or disabled. - """ - if self.config_files_combobox.isEnabled(): - self.config_files_combobox.setStyleSheet(f"""QComboBox {{ - color: {get_theme('dark_blue.xml')['primaryLightColor']}; - }}""") - else: - self.config_files_combobox.setStyleSheet("QComboBox { color: gray; }") - - def accept(self): - """ - Saves the configuration file and closes the wizard. - """ - # Get the filename from the QLineEdit or QComboBox - filename = self.filename_edit.text() - if not filename: - filename = self.config_files_combobox.currentText() - - # Add .yml extension if not provided by the user - if not filename.endswith('.yml'): - filename += '.yml' - - # Save the updated config data - new_checkbox_labels = [] - for i in range(self.labels_layout.count()): - line_edit = self.labels_layout.itemAt(i).widget() - if line_edit.text(): - new_checkbox_labels.append(line_edit.text()) - - self.config_data['checkboxes'] = new_checkbox_labels - self.config_data['tristate_cboxes'] = bool(self.tristate_checkbox.checkState()) - self.config_data['max_backups'] = self.backup_spinbox.value() - self.config_data['backup_interval'] = self.backup_int_spinbox.value() - self.config_data['backup_dir'] = self.backup_dir_edit.text() - self.config_data['log_dir'] = self.log_dir_edit.text() - - save_path = os.path.join(resource_dir, filename) - - # Save the config file - with open(save_path, 'w') as f: - yaml.dump(self.config_data, f) - - # Makes a log of the new configuration - logger, console_msg = setup_logging(self.config_data['log_dir']) - logger.info(f"Configuration saved to {save_path}") - - # Inform the user that the configuration has been saved - QMessageBox.information(self, "Configuration Saved", "The configuration has been saved.") - - super().accept() - - -if __name__ == '__main__': - - # Create the application and apply the qt material stylesheet - app = QApplication([]) - apply_stylesheet(app, theme='dark_blue.xml') - - # Set the directory of the main.py file as the default directory for the config files - default_dir = resource_dir - - # Load the last config file used - settings = QSettings('SpeedyQC', 'DicomViewer') - config_file = settings.value('config_file', os.path.join(default_dir, 'config.yml')) - - # Create the configuration wizard - wizard = ConfigurationWizard(config_file) - - # Run the wizard - wizard.show() - - app.exec() diff --git a/speedy_qc/graphics.py b/speedy_qc/graphics.py new file mode 100644 index 0000000..914b85d --- /dev/null +++ b/speedy_qc/graphics.py @@ -0,0 +1,236 @@ +""" +graphics.py: Custom graphics items for the Speedy QC Package. + +This module defines custom graphics items for the Speedy QC Package to allow for drawing bounding boxes on images. + +Classes: + - BoundingBoxItem: Custom graphics item to handle drawing bounding boxes on images. + - CustomGraphicsView: Custom graphics view to handle drawing bounding boxes on images. +""" + +from PyQt6.QtCore import * +from PyQt6.QtGui import * +from PyQt6.QtWidgets import * +from typing import Optional, Dict, List +from speedy_qc.utils import ConnectionManager +import math + + +class BoundingBoxItem(QGraphicsRectItem): + """ + Custom graphics item to handle drawing bounding boxes on images. + This class inherits from QGraphicsRectItem and provides selectable, movable, and removable bounding boxes. + + Methods: + - contextMenuEvent: Show a context menu when the bounding box is right-clicked, allowing them to be removed. + """ + def __init__(self, rect, color, parent=None): + """ + Initializes a new BoundingBoxItem with the given rectangle, color, and optional parent item. + + :param rect: QRectF, the rectangle defining the bounding box's geometry + :param color: QColor, the color of the bounding box's border + :param parent: QGraphicsItem, optional parent item (default: None) + """ + super().__init__(rect, parent) + self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, True) + self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, True) + self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges, True) + self.setAcceptHoverEvents(True) + self.setPen(QPen(color, 5)) + + def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent): + """ + Show a context menu when the bounding box is right-clicked, allowing them to be removed. + + :param event: QGraphicsSceneContextMenuEvent, the event that triggered the context menu. + """ + menu = QMenu() + remove_action = menu.addAction("Remove") + selected_action = menu.exec(event.screenPos()) + + if selected_action == remove_action: + self.scene().removeItem(self) + + def rotate(self, rotation_angle: float, center: QPointF): + """ + Rotate the bounding box around the given center point by the given rotation angle. + + :param rotation_angle: float, the angle to rotate the bounding box by + :type rotation_angle: float + :param center: QPointF, the center point to rotate the bounding box around + :type center: QPointF + """ + rect = self.rect() + rect_center = rect.center() + + # Translate rect center to the origin + rect_center -= QPointF(center.x(), center.y()) + + # Rotate rect center around the origin + angle_rad = math.radians(rotation_angle) + new_x = rect_center.x() * math.cos(angle_rad) - rect_center.y() * math.sin(angle_rad) + new_y = rect_center.x() * math.sin(angle_rad) + rect_center.y() * math.cos(angle_rad) + + # Translate rect center back to its original position + new_center = QPointF(new_x, new_y) + QPointF(center.x(), center.y()) + + # Swap the width and height of the bounding box if the rotation angle is 90 or 270 degrees + if rotation_angle % 180 != 0: + new_width = rect.height() + new_height = rect.width() + rect.setWidth(new_width) + rect.setHeight(new_height) + + # Set the new rect center + rect.moveCenter(new_center) + + # Update the bounding box rect + self.setRect(rect) + + +class CustomGraphicsView(QGraphicsView): + """ + Custom graphics view to handle zooming, panning, resizing and drawing bounding boxes. This class is used to display + the images and is the central widget of the main window. + + Methods: + - zoom_in (self): Zoom in by a factor of 1.2 (20%). + - zoom_out (self): Zoom out by a factor of 0.8 (20%). + - on_main_window_resized (self): Resize the image and maintain the same zoom when the main window is resized. + - mousePressEvent (self, event: QMouseEvent): Start drawing a bounding box when the left mouse button is + pressed. + - mouseMoveEvent (self, event: QMouseEvent): Update the bounding box when the mouse is moved. + - mouseReleaseEvent (self, event: QMouseEvent): Finish drawing the bounding box when the left mouse button is + released. + - set_current_finding (self, finding: str, color: QColor): Set the current finding/checkbox to give context to + any bounding box drawn. + - remove_all_bounding_boxes (self): Remove all bounding boxes from the scene. + - add_bboxes (self, rect_items: dict): Add previously drawn bounding boxes to the scene. + """ + def __init__(self, parent: Optional[QWidget] = None, main_window = False): + """ + Initialize the custom graphics view. + """ + super().__init__() + # self.connections = {} + self.connection_manager = ConnectionManager() + self.setRenderHint(QPainter.RenderHint.Antialiasing) + self.setOptimizationFlag(QGraphicsView.OptimizationFlag.DontAdjustForAntialiasing, True) + self.setOptimizationFlag(QGraphicsView.OptimizationFlag.DontSavePainterState, True) + self.setViewportUpdateMode(QGraphicsView.ViewportUpdateMode.FullViewportUpdate) + self.zoom = 1.0 + self.start_rect = None + self.current_finding = None + self.current_color = None + # self.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + + self.touch_points = [] + self.rect_items = {} + + if main_window: + self.connection_manager.connect(parent.resized, self.on_main_window_resized) + + def zoom_in(self): + """ + Zoom in by a factor of 1.2 (20%). + """ + factor = 1.2 + self.zoom *= factor + self.scale(factor, factor) + + def zoom_out(self): + """ + Zoom out by a factor of 0.8 (20%). + """ + factor = 0.8 + self.zoom /= factor + self.scale(factor, factor) + + def on_main_window_resized(self): + """ + Resize the image and maintain the same zoom when the main window is resized. + """ + if self.scene() and self.scene().items(): + self.fitInView(self.scene().items()[-1].boundingRect(), Qt.AspectRatioMode.KeepAspectRatio) + self.scale(self.zoom, self.zoom) + + def mousePressEvent(self, event: QMouseEvent): + """ + Start drawing a bounding box when the left mouse button is pressed. + + :param event: QMouseEvent, the mouse press event containing information about the button and position + """ + if event.button() == Qt.MouseButton.LeftButton: + if self.scene() and self.current_finding: + pos = self.mapToScene(event.position().toPoint()) + color = self.current_color + self.start_rect = BoundingBoxItem(QRectF(pos, QSizeF(0, 0)), color) + self.scene().addItem(self.start_rect) + if self.current_finding in self.rect_items: + self.rect_items[self.current_finding].append(self.start_rect) + else: + self.rect_items[self.current_finding] = [self.start_rect] + super().mousePressEvent(event) + + def mouseMoveEvent(self, event: QMouseEvent): + """ + Update the size of the bounding box as the mouse is moved. + + :param event: QMouseEvent, the mouse move event containing information about the buttons and position + """ + if event.buttons() & Qt.MouseButton.LeftButton: + if self.start_rect: + pos = self.mapToScene(event.position().toPoint()) + width = pos.x() - self.start_rect.rect().x() + height = pos.y() - self.start_rect.rect().y() + self.start_rect.setRect(QRectF(self.start_rect.rect().x(), self.start_rect.rect().y(), width, height)) + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: QMouseEvent): + """ + Stop drawing the bounding box when the left mouse button is released. + + :param event: QMouseEvent, the mouse release event containing information about the button and position + """ + if event.button() == Qt.MouseButton.LeftButton: + if self.start_rect: + self.start_rect = None + super().mouseReleaseEvent(event) + + def set_current_finding(self, finding: Optional[str], color: Optional[QColor]): + """ + Set the current finding and color to be used when drawing bounding boxes. + + :param finding: str, the name of the finding/checkbox + :param color: QColor, the color to be used when drawing the bounding box + """ + self.current_finding = finding + self.current_color = color + + def remove_all_bounding_boxes(self): + """ + Remove all bounding boxes from the scene. + """ + for finding, bboxes in self.rect_items.items(): + for bbox in bboxes: + self.scene().removeItem(bbox) + self.rect_items.clear() + + def add_bboxes(self, rect_items: Dict[str, List]): + """ + Add previously drawn bounding boxes to the scene. + + :param rect_items: dict, a dictionary of finding/checkbox names and their corresponding bounding boxes + """ + # Add previously drawn bounding boxes to the scene + for finding, bboxes in rect_items.items(): + for bbox in bboxes: + self.scene().addItem(bbox) + if finding in self.rect_items: + self.rect_items[finding].append(bbox) + else: + self.rect_items[finding] = [bbox] + + + diff --git a/speedy_qc/main.py b/speedy_qc/main.py index 05d46e5..fc491dc 100644 --- a/speedy_qc/main.py +++ b/speedy_qc/main.py @@ -26,9 +26,9 @@ from PyQt6.QtWidgets import * from qt_material import apply_stylesheet -from speedy_qc.main_window import MainWindow -from speedy_qc.config_wizard import ConfigurationWizard -from speedy_qc.custom_windows import LoadMessageBox, SetupWindow +from speedy_qc.main_app import MainApp +from speedy_qc.wizard import ConfigurationWizard +from speedy_qc.windows import LoadMessageBox, SetupWindow if hasattr(sys, '_MEIPASS'): # This is a py2app executable @@ -40,8 +40,6 @@ resource_dir = os.path.join(os.path.dirname(os.path.abspath("__main__")), 'speedy_qc') - - def main(theme='qt_material', material_theme='dark_blue.xml', icon_theme='qtawesome'): """ Main function. Creates the main window and runs the application. @@ -62,6 +60,44 @@ def main(theme='qt_material', material_theme='dark_blue.xml', icon_theme='qtawes QIcon class. """ + def cleanup(): + # Cleanup load intro window + try: + # This might raise an exception if setup_window was never created, + # so we catch the exception and ignore it. + load_msg_box.close() + load_msg_box.deleteLater() + except NameError: + pass + + # Cleanup main window + try: + # This might raise an exception if setup_window was never created, + # so we catch the exception and ignore it. + window.close() + window.deleteLater() + except NameError: + pass + + # Cleanup setup window + try: + # This might raise an exception if setup_window was never created, + # so we catch the exception and ignore it. + setup_window.close() + setup_window.deleteLater() + except NameError: + pass + + # Cleanup wizard + try: + # This might raise an exception if wizard was never created, + # so we catch the exception and ignore it. + wizard.close() + wizard.deleteLater() + except NameError: + pass + return + # Create the application app = QApplication(sys.argv) @@ -77,6 +113,9 @@ def main(theme='qt_material', material_theme='dark_blue.xml', icon_theme='qtawes # Create the initial dialog box load_msg_box = LoadMessageBox() result = load_msg_box.exec() + config_file = load_msg_box.config_combo.currentText() + print("main", config_file) + load_msg_box.save_last_config(config_file) settings = QSettings('SpeedyQC', 'DicomViewer') @@ -88,19 +127,19 @@ def main(theme='qt_material', material_theme='dark_blue.xml', icon_theme='qtawes if result == setup_window.DialogCode.Accepted: # Create the main window and pass the dicom directory - window = MainWindow(settings) + window = MainApp(settings) window.show() else: + cleanup() sys.exit() # User selects to `Cancel` -> exit the application elif result == load_msg_box.DialogCode.Rejected: + cleanup() sys.exit() # User selects to `Conf. Wizard` -> show the ConfigurationWizard else: - config_file = load_msg_box.config_combo.currentText() - load_msg_box.save_last_config(config_file) if hasattr(sys, '_MEIPASS'): # This is a py2app executable resource_dir = sys._MEIPASS @@ -109,10 +148,12 @@ def main(theme='qt_material', material_theme='dark_blue.xml', icon_theme='qtawes resource_dir = os.path.dirname(os.path.abspath("__main__")) else: resource_dir = os.path.join(os.path.dirname(os.path.abspath("__main__")), 'speedy_qc') - wizard = ConfigurationWizard(os.path.join(resource_dir, 'config.yml')) + wizard = ConfigurationWizard(os.path.join(resource_dir, config_file)) wizard.show() - sys.exit(app.exec()) + exit_code = app.exec() + cleanup() + sys.exit(exit_code) if __name__ == '__main__': diff --git a/speedy_qc/main_window.py b/speedy_qc/main_app.py similarity index 68% rename from speedy_qc/main_window.py rename to speedy_qc/main_app.py index 03c4a40..933cb7e 100644 --- a/speedy_qc/main_window.py +++ b/speedy_qc/main_app.py @@ -1,12 +1,10 @@ """ -main_window.py +main_app.py This module contains the main window of the application. Classes: - - MainWindow: Main window of the application. - - CustomGraphicsView: Custom graphics view to handle zooming, panning, resizing and drawing bounding boxes. - - BoundingBoxItem: Custom graphics item to draw bounding boxes. + - MainApp: Main window of the application. """ import os @@ -22,14 +20,16 @@ from PyQt6.QtCore import QTimer import datetime import json -import math -from typing import Optional, Dict, List, Tuple +from typing import Dict, List, Tuple import matplotlib.pyplot as plt import sys -import collections +from math import ceil +import imageio as iio +from functools import partial -from speedy_qc.custom_windows import AboutMessageBox -from speedy_qc.utils import ConnectionManager, open_yml_file, setup_logging +from speedy_qc.windows import AboutMessageBox +from speedy_qc.utils import ConnectionManager, open_yml_file, setup_logging, bytescale, convert_to_checkstate +from speedy_qc.graphics import CustomGraphicsView, BoundingBoxItem if hasattr(sys, '_MEIPASS'): # This is a py2app executable @@ -42,238 +42,16 @@ outer_setting = QSettings('SpeedyQC', 'DicomViewer') config_file = outer_setting.value("last_config_file", os.path.join(resource_dir, "config.yml")) -config_data = open_yml_file(os.path.join(resource_dir, config_file)) -logger, console_msg = setup_logging(config_data['log_dir']) +config = open_yml_file(os.path.join(resource_dir, config_file)) +logger, console_msg = setup_logging(config['log_dir']) -class CustomGraphicsView(QGraphicsView): +class MainApp(QMainWindow): """ - Custom graphics view to handle zooming, panning, resizing and drawing bounding boxes. This class is used to display - the DICOM images and is the central widget of the main window. - - Methods: - - zoom_in (self): Zoom in by a factor of 1.2 (20%). - - zoom_out (self): Zoom out by a factor of 0.8 (20%). - - on_main_window_resized (self): Resize the image and maintain the same zoom when the main window is resized. - - mousePressEvent (self, event: QMouseEvent): Start drawing a bounding box when the left mouse button is - pressed. - - mouseMoveEvent (self, event: QMouseEvent): Update the bounding box when the mouse is moved. - - mouseReleaseEvent (self, event: QMouseEvent): Finish drawing the bounding box when the left mouse button is - released. - - set_current_finding (self, finding: str, color: QColor): Set the current finding/checkbox to give context to - any bounding box drawn. - - remove_all_bounding_boxes (self): Remove all bounding boxes from the scene. - - add_bboxes (self, rect_items: dict): Add previously drawn bounding boxes to the scene. - """ - def __init__(self, parent: Optional[QWidget] = None): - """ - Initialize the custom graphics view. - """ - super().__init__() - # self.connections = {} - self.connection_manager = ConnectionManager() - self.setRenderHint(QPainter.RenderHint.Antialiasing) - self.setOptimizationFlag(QGraphicsView.OptimizationFlag.DontAdjustForAntialiasing, True) - self.setOptimizationFlag(QGraphicsView.OptimizationFlag.DontSavePainterState, True) - self.setViewportUpdateMode(QGraphicsView.ViewportUpdateMode.FullViewportUpdate) - self.zoom = 1.0 - self.start_rect = None - self.current_finding = None - self.current_color = None - # self.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) - - self.touch_points = [] - self.rect_items = {} - - if isinstance(parent, MainWindow): - self.connection_manager.connect(parent.resized, self.on_main_window_resized) - - def zoom_in(self): - """ - Zoom in by a factor of 1.2 (20%). - """ - factor = 1.2 - self.zoom *= factor - self.scale(factor, factor) - - def zoom_out(self): - """ - Zoom out by a factor of 0.8 (20%). - """ - factor = 0.8 - self.zoom /= factor - self.scale(factor, factor) - - def on_main_window_resized(self): - """ - Resize the image and maintain the same zoom when the main window is resized. - """ - if self.scene() and self.scene().items(): - self.fitInView(self.scene().items()[-1].boundingRect(), Qt.AspectRatioMode.KeepAspectRatio) - self.scale(self.zoom, self.zoom) - - def mousePressEvent(self, event: QMouseEvent): - """ - Start drawing a bounding box when the left mouse button is pressed. - - :param event: QMouseEvent, the mouse press event containing information about the button and position - """ - if event.button() == Qt.MouseButton.LeftButton: - if self.scene() and self.current_finding: - pos = self.mapToScene(event.position().toPoint()) - color = self.current_color - self.start_rect = BoundingBoxItem(QRectF(pos, QSizeF(0, 0)), color) - self.scene().addItem(self.start_rect) - if self.current_finding in self.rect_items: - self.rect_items[self.current_finding].append(self.start_rect) - else: - self.rect_items[self.current_finding] = [self.start_rect] - super().mousePressEvent(event) - - def mouseMoveEvent(self, event: QMouseEvent): - """ - Update the size of the bounding box as the mouse is moved. - - :param event: QMouseEvent, the mouse move event containing information about the buttons and position - """ - if event.buttons() & Qt.MouseButton.LeftButton: - if self.start_rect: - pos = self.mapToScene(event.position().toPoint()) - width = pos.x() - self.start_rect.rect().x() - height = pos.y() - self.start_rect.rect().y() - self.start_rect.setRect(QRectF(self.start_rect.rect().x(), self.start_rect.rect().y(), width, height)) - super().mouseMoveEvent(event) - - def mouseReleaseEvent(self, event: QMouseEvent): - """ - Stop drawing the bounding box when the left mouse button is released. - - :param event: QMouseEvent, the mouse release event containing information about the button and position - """ - if event.button() == Qt.MouseButton.LeftButton: - if self.start_rect: - self.start_rect = None - super().mouseReleaseEvent(event) - - def set_current_finding(self, finding: Optional[str], color: Optional[QColor]): - """ - Set the current finding and color to be used when drawing bounding boxes. - - :param finding: str, the name of the finding/checkbox - :param color: QColor, the color to be used when drawing the bounding box - """ - self.current_finding = finding - self.current_color = color - - def remove_all_bounding_boxes(self): - """ - Remove all bounding boxes from the scene. - """ - for finding, bboxes in self.rect_items.items(): - for bbox in bboxes: - self.scene().removeItem(bbox) - self.rect_items.clear() - - def add_bboxes(self, rect_items: Dict[str, List]): - """ - Add previously drawn bounding boxes to the scene. - - :param rect_items: dict, a dictionary of finding/checkbox names and their corresponding bounding boxes - """ - # Add previously drawn bounding boxes to the scene - for finding, bboxes in rect_items.items(): - for bbox in bboxes: - self.scene().addItem(bbox) - if finding in self.rect_items: - self.rect_items[finding].append(bbox) - else: - self.rect_items[finding] = [bbox] - - -class BoundingBoxItem(QGraphicsRectItem): - """ - Custom graphics item to handle drawing bounding boxes on DICOM images. - This class inherits from QGraphicsRectItem and provides selectable, movable, and removable bounding boxes. - - Methods: - - contextMenuEvent: Show a context menu when the bounding box is right-clicked, allowing them to be removed. - """ - def __init__(self, rect, color, parent=None): - """ - Initializes a new BoundingBoxItem with the given rectangle, color, and optional parent item. - - :param rect: QRectF, the rectangle defining the bounding box's geometry - :param color: QColor, the color of the bounding box's border - :param parent: QGraphicsItem, optional parent item (default: None) - """ - super().__init__(rect, parent) - self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, True) - self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, True) - self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges, True) - self.setAcceptHoverEvents(True) - self.setPen(QPen(color, 5)) - - def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent): - """ - Show a context menu when the bounding box is right-clicked, allowing them to be removed. - - :param event: QGraphicsSceneContextMenuEvent, the event that triggered the context menu. - """ - menu = QMenu() - remove_action = menu.addAction("Remove") - selected_action = menu.exec(event.screenPos()) - - if selected_action == remove_action: - self.scene().removeItem(self) + Main window of the application. - -class MainWindow(QMainWindow): - """ - Main window for the application. - - Methods: - - prep_first_image (self): Prepare the bounding boxes to display the first image. - - init_connections (self): Initialize the connections between the UI elements and their corresponding functions. - - backup_file (self): Save backup file if triggered by the timer. - - wheelEvent (self, event: QWheelEvent): Changes windowing when the mouse wheel is scrolled with the Ctrl/Cmd - or Shift key pressed. - - open_findings_yml (self): Open the relevant config .yml file with the settings for the app. - - on_text_changed (self): Update the notes dictionary when the text in the text box is changed. - - invert_greyscale (self): Invert the greyscale of the image. - - rotate_image_left (self): Rotate the image 90 degrees to the left. - - rotate_image_right (self): Rotate the image 90 degrees to the right. - - apply_stored_rotation (self): Restore any rotation previously applied to the image. - - rotate_bounding_boxes (self, filename: str, rotation_angle: int, reverse: bool = False): Rotate the bounding - boxes to match the image is rotation. - - resizeEvent (self, event: QResizeEvent): Trigger the CustomGraphicsView to handle resizing and zoom of the - image when the window is resized. - - load_file (self): Load the DICOM file at the given index and apply the look-up tables. - - load_image (self): Add the image to the scene. - - update_image (self): Update the image with the applied windowing. - - create_checkboxes (self): Create the checkboxes for the findings from the config file - - set_checkbox_toolbar (self): Set up the checkbox toolbar on the right of the window. - - restore_saved_state (self): Restore the saved state of the checkboxes from the QSettings. - - reset_window_sliders (self): Reset the windowing sliders to their default values. - - change_image (self, direction: str, prev_failed: bool = False): Load the next or previous image in the - directory. - - previous_image (self): Load the previous image in the directory using change_image. - - next_image (self, prev_failed: bool = False): Load the next image in the directory using change_image. - - is_image_viewed (self): Check if the image has been viewed previously. - - set_checkbox_value (self): Set the checkbox value to True or False when clicked. - - keyPressEvent (self, event: QKeyEvent): Handle key presses for the shortcuts. - - save_settings (self): Save settings to the QSettings. - - save_to_json (self): Direct the save process to 'save as' dialog or just to save to the current file. - - save_as (self): Creates and handles a dialog to save the current outputs to a new location. - - save_json (self, selected_file: str): Save the current outputs to a JSON file. - - load_from_json (self): Load progress from a JSON file. - - load_bounding_box (self, file: str, finding: str, raw_rect: tuple): Load the bounding box from the JSON file - and convert into BoundingBoxItem instance. - - on_checkbox_changes (self, state: int): Triggered when the checkbox state is changed and updates the UI. - - assign_colours_to_findings (self): Assign a colour to each finding/checkbox. - - closeEvent (self, event: QCloseEvent): Triggered when the window is closed and saves the settings. - - init_menus (self): Initialize the menus for the main window. - - show_about (self): Show the 'About' window. - - quit_app (self): Quit the application and disconnect all signals. + :param settings: The loaded app settings. + :type settings: QSettings """ resized = pyqtSignal() @@ -281,7 +59,8 @@ def __init__(self, settings): """ Initialize the main window. - :param settings: QSettings, the loaded app settings. + :param settings: The loaded app settings. + :type settings: QSettings """ super().__init__() # Initialize UI @@ -294,14 +73,16 @@ def __init__(self, settings): # Initialize variables self.current_index = 0 self.checkboxes = {} - self.tristate_cboxes = False + self.radiobuttons = {} + self.ratiobuttons_boxes = {} self.colors = {} - self.dir_path = self.settings.value("dicom_path", "") + self.dir_path = self.settings.value("image_path", "") self.json_path = self.settings.value("json_path", "") self.backup_interval = self.settings.value("backup_interval", 5, type=int) + self.image = None # Set the initial window size - self.resize(1250, 1000) + self.resize(self.settings.value('window_size', QSize(800, 600))) # Set the default colors for the icons qta.set_defaults( @@ -334,27 +115,38 @@ def __init__(self, settings): self.setWindowIcon(QIcon(icon_path)) # Set the central widget to the image viewer - self.image_view = CustomGraphicsView(self) + self.image_view = CustomGraphicsView(self, main_window=True) self.image_view.resize(self.size()) - # Load the DICOM file list - self.file_list = sorted([f for f in os.listdir(self.dir_path) if f.endswith('.dcm')]) + # Load the image file list + # self.file_list = sorted([f for f in os.listdir(self.dir_path) if f.endswith('.dcm')]) + self.file_list = sorted([f for f in os.listdir(self.dir_path) if f.endswith(( + '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif', '.dcm', '.dicom', + ))]) # Load the configuration file config = self.open_config_yml() - self.findings = config['checkboxes'] - self.tristate_cboxes = bool(config['tristate_cboxes']) - self.max_backups = config['max_backups'] - self.backup_dir = config['backup_dir'] - if "~" in self.backup_dir: - self.backup_dir = os.path.expanduser(self.backup_dir) - self.backup_interval = config['backup_interval'] + self.findings = config.get('checkboxes', []) + self.radiobutton_groups = config.get('radiobuttons', {}) + # self.task = "medical diagnosis" + self.tristate_checkboxes = config.get('tristate_checkboxes', False) + self.max_backups = config.get('max_backups', 10) + self.backup_dir = config.get('backup_dir', os.path.expanduser('~/speedy_qc/backups')) + self.backup_dir = os.path.expanduser(self.backup_dir) + self.backup_interval = config.get('backup_interval', 5) # Initialize dictionaries for outputs self.viewed_values = {f: False for f in self.file_list} self.rotation = {f: 0 for f in self.file_list} self.notes = {f: "" for f in self.file_list} - self.checkbox_values = {f: {finding: 0 for finding in self.findings} for f in self.file_list} + if bool(self.findings): + self.checkbox_values = {f: {finding: 0 for finding in self.findings} for f in self.file_list} + else: + self.checkbox_values = {f: {} for f in self.file_list} + if bool(self.radiobutton_groups): + self.radiobutton_values = {f: {group['title']: None for group in self.radiobutton_groups} for f in self.file_list} + else: + self.radiobutton_values = {f: {} for f in self.file_list} self.bboxes = {f: {} for f in self.file_list} # Assign colors to findings @@ -396,21 +188,18 @@ def __init__(self, settings): self.file_tool_bar.addAction(self.saveAction) self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.file_tool_bar) - # Create the checkbox toolbar - self.checkbox_toolbar = QToolBar(self) - self.create_checkboxes() - self.set_checkbox_toolbar() - # Add the textbox for notes - self.textbox = QTextEdit() - self.textbox.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth) - text_action = QWidgetAction(self) - text_action.setDefaultWidget(self.textbox) + # Create the labelling toolbar + self.labelling_toolbar = QToolBar(self) + self.viewed_label = QLabel(self) + self.viewed_icon = QLabel(self) + if bool(self.findings): + self.create_checkboxes() + if bool(self.radiobutton_groups): + for group in self.radiobutton_groups: + self.create_radiobuttons(group['title'], group['labels']) self.textbox_label = QLabel(self) - self.textbox_label.setAlignment(Qt.AlignmentFlag.AlignLeft) - self.textbox_label.setObjectName("Notes") - self.textbox_label.setText("NOTES:") - self.checkbox_toolbar.addWidget(self.textbox_label) - self.checkbox_toolbar.addAction(text_action) + self.textbox = QTextEdit(self) + self.set_labelling_toolbar() # Create the image toolbar for image manipulation self.image_toolbar = QToolBar(self) @@ -509,7 +298,13 @@ def __init__(self, settings): percent_viewed = 100 * len([value for value in self.viewed_values.values() if value]) / len(self.file_list) self.update_progress_bar(percent_viewed) - def update_progress_bar(self, progress): + def update_progress_bar(self, progress: float): + """ + Update the progress bar with the current progress + + :param progress: the current progress + :type progress: float + """ self.progress_bar.setValue(int(progress)) def prep_first_image(self): @@ -545,6 +340,9 @@ def init_connections(self): def backup_file(self) -> List[str]: """ Backs up the current file to a backup folder when triggered by the timer. + + :return: A list of backup files + :rtype: List[str] """ backup_folder_path = self.backup_dir @@ -583,7 +381,8 @@ def wheelEvent(self, event: QWheelEvent): - Ctrl/Cmd + Scroll: Change window width - Shift + Scroll: Change window center - :param event: QWheelEvent + :param event: The wheelEvent function to override + :type event: QWheelEvent """ if event.modifiers() == Qt.KeyboardModifier.ControlModifier: # check if Ctrl key is pressed delta = event.angleDelta().y() # get the scroll direction @@ -608,10 +407,13 @@ def open_config_yml(self) -> Dict: """ Opens the config .yml file and returns the data, including the list of findings/checkboxes, the maximum number of backups, the backup directory and the log directory. + + :return: The config data + :rtype: Dict """ last_config_file = self.settings.value("last_config_file", "config.yml") - cbox_file = os.path.join(resource_dir, last_config_file) - return open_yml_file(cbox_file) + conf_file = os.path.join(resource_dir, last_config_file) + return open_yml_file(conf_file) def on_text_changed(self): """ @@ -674,9 +476,12 @@ def rotate_bounding_boxes(self, filename: str, rotation_angle: int, reverse: boo """ Rotates the bounding boxes around the center of the image to match the image rotation. - :param filename: str, the name of the image file. - :param rotation_angle: int, the angle to rotate the bounding boxes by. - :param reverse: bool, if True, the rotation is reversed, default is False. + :param filename: The name of the image file. + :type filename: str + :param rotation_angle: The angle to rotate the bounding boxes by. + :type rotation_angle: int + :param reverse: If True, the rotation is reversed, default is False. + :type reverse: bool """ if not reverse: rotation_angle = -rotation_angle @@ -685,62 +490,43 @@ def rotate_bounding_boxes(self, filename: str, rotation_angle: int, reverse: boo center = self.pixmap_item.boundingRect().center() for finding, bboxes in self.bboxes[filename].items(): for bbox in bboxes: - rect = bbox.rect() - - rect_center = rect.center() - - # Translate rect center to the origin - rect_center -= QPointF(center.x(), center.y()) - - # Rotate rect center around the origin - angle_rad = math.radians(rotation_angle) - new_x = rect_center.x() * math.cos(angle_rad) - rect_center.y() * math.sin(angle_rad) - new_y = rect_center.x() * math.sin(angle_rad) + rect_center.y() * math.cos(angle_rad) - - # Translate rect center back to its original position - new_center = QPointF(new_x, new_y) + QPointF(center.x(), center.y()) - - # Swap the width and height of the bounding box if the rotation angle is 90 or 270 degrees - if rotation_angle % 180 != 0: - new_width = rect.height() - new_height = rect.width() - rect.setWidth(new_width) - rect.setHeight(new_height) - - # Set the new rect center - rect.moveCenter(new_center) - - # Update the bounding box rect - bbox.setRect(rect) + bbox.rotate(rotation_angle, center) def resizeEvent(self, event: QResizeEvent): """ Emits a signal to update the image size and zoom when the window is resized. - :param event: QResizeEvent, the resize event containing the old and new sizes of the widget + :param event: The resize event containing the old and new sizes of the widget + :type event: QResizeEvent """ super().resizeEvent(event) self.resized.emit() def load_file(self): """ - Loads the DICOM file and applies the look-up tables. + Loads the image file and applies the look-up tables. """ file_path = os.path.join(self.dir_path, self.file_list[self.current_index]) + file_extension = os.path.splitext(file_path)[1] try: - # Read the DICOM file - ds = pydicom.dcmread(file_path) - self.image = ds.pixel_array - self.image = apply_modality_lut(self.image, ds) - self.image = apply_voi_lut(self.image.astype(int), ds, 0) - - # Convert the pixel array to an 8-bit integer array - if ds.BitsStored != 8: - _min = np.amin(self.image) - _max = np.amax(self.image) - self.image = (self.image - _min) * 255.0 / (_max - _min) - self.image = np.uint8(self.image) + if file_extension == ".dcm": + # Read the DICOM file + ds = pydicom.dcmread(file_path) + self.image = ds.pixel_array + self.image = apply_modality_lut(self.image, ds) + self.image = apply_voi_lut(self.image.astype(int), ds, 0) + # Convert the pixel array to an 8-bit integer array + if ds.BitsStored != 8: + _min = np.amin(self.image) + _max = np.amax(self.image) + self.image = (self.image - _min) * 255.0 / (_max - _min) + self.image = np.uint8(self.image) + else: + # Read the image file + raw_image = iio.imread(file_path) + if raw_image.dtype == np.uint8: + self.image = bytescale(raw_image) except Exception as e: # Handle the exception (e.g. display an error message) QMessageBox.critical(self, "Error", f"Failed to load file:\n{str(e)}", QMessageBox.StandardButton.Ok, @@ -783,8 +569,7 @@ def create_checkboxes(self): for cbox in self.findings: self.checkboxes[cbox] = QCheckBox(cbox, self) self.checkboxes[cbox].setObjectName(cbox) - if self.tristate_cboxes: - self.checkboxes[cbox].setTristate(self.tristate_cboxes) + self.checkboxes[cbox].setTristate(self.tristate_checkboxes) semitrans_color = QColor(self.colors[cbox]) semitrans_color.setAlpha(64) self.checkboxes[cbox].setStyleSheet(f"QCheckBox::indicator:checked {{ " @@ -801,59 +586,163 @@ def create_checkboxes(self): f"width: 18px;" f"height: 18px;" f"}} ") - self.checkboxes[cbox].setChecked(self.checkbox_values.get(filename, 0).get(cbox, 0)) + self.checkboxes[cbox].setCheckState(convert_to_checkstate(self.checkbox_values.get(filename, 0).get(cbox, 0))) self.connection_manager.connect(self.checkboxes[cbox].stateChanged, self.on_checkbox_changed) - def set_checkbox_toolbar(self): + def create_radiobuttons(self, name, options_list): + """ + Creates the radio buttons for the given options. + + :param name: The name of the radio button group + :type name: str + :param options_list: The list of options + :type options_list: list + """ + group_box = QGroupBox(name) + options = [str(option) for option in options_list] + max_label_length = max([len(label) for label in options]) + layout = QGridLayout() + num_columns = ceil(4 / max_label_length) # Adjust the number of columns based on the label length + + # Create a new button group + button_group = QButtonGroup() + # Create the radio buttons + for i, option in enumerate(options): + radiobutton = QRadioButton(option) + row = i // num_columns + col = i % num_columns + layout.addWidget(radiobutton, row, col) + button_group.addButton(radiobutton, i) + + group_box.setLayout(layout) + self.ratiobuttons_boxes[name] = group_box + self.radiobuttons[name] = button_group + self.set_checked_radiobuttons() + self.connection_manager.connect(self.radiobuttons[name].idToggled, + partial(self.on_radiobutton_changed, name)) + + def on_radiobutton_changed(self, name, id, checked): + """ + Called when a radio button is changed. + + :param name: Name of the radio button group + :type name: str + :param id: ID of the radio button + :type id: int + :param checked: Whether the radio button is checked + :type checked: bool + """ + if checked: + self.radiobutton_values[self.file_list[self.current_index]][name] = id + + def uncheck_all_radiobuttons(self): + """ + Unchecks all the radio buttons. + """ + for button_group in self.radiobuttons.values(): + button_group.setExclusive(False) + for button in button_group.buttons(): + button.setChecked(False) + button_group.setExclusive(True) + + def update_all_radiobutton_values(self): + """ + Updates the values of all the radio buttons. + """ + for name, button_group in self.radiobuttons.items(): + self.radiobutton_values[self.file_list[self.current_index]][name] = button_group.checkedId() + + def set_checked_radiobuttons(self): + """ + Sets the checked radio buttons. + """ + for name in self.radiobuttons.keys(): + if self.radiobutton_values.get(self.file_list[self.current_index]).get(name) is not None: + self.radiobuttons[name].button( + self.radiobutton_values.get(self.file_list[self.current_index]).get(name) + ).setChecked(True) + else: + self.uncheck_all_radiobuttons() + + def set_labelling_toolbar(self): """ Sets the checkbox toolbar. """ + # Create a scroll area and widget + scroll = QScrollArea(self) + scroll.setWidgetResizable(True) + widget = QWidget() + layout = QVBoxLayout() + widget.setLayout(layout) + scroll.setWidget(widget) + spacer_item = QSpacerItem(0, 5, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) spacer_widget = QWidget() spacer_widget.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) spacer_widget.setLayout(QHBoxLayout()) spacer_widget.layout().addItem(spacer_item) - self.checkbox_toolbar.addWidget(spacer_widget) + layout.addWidget(spacer_widget) - self.viewed_label = QLabel(self) self.viewed_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) self.viewed_label.setObjectName("viewed_label") self.viewed_label.setText(("" if self.is_image_viewed() else "NOT ") + "PREVIOUSLY VIEWED") - self.checkbox_toolbar.addWidget(self.viewed_label) - - self.viewed_icon = QLabel(self) + layout.addWidget(self.viewed_label) self.viewed_icon.setAlignment(Qt.AlignmentFlag.AlignHCenter) self.viewed_icon.setPixmap( QPixmap(self.icons['viewed'].pixmap(self.file_tool_bar.iconSize() * 2) if self.is_image_viewed() else self.icons['not_viewed'].pixmap(self.file_tool_bar.iconSize() * 2)) ) - self.checkbox_toolbar.addWidget(self.viewed_icon) + layout.addWidget(self.viewed_icon) - # self.checkbox_toolbar.addWidget(spacer_widget) + # self.labelling_toolbar.addWidget(spacer_widget) spacer_widget = QWidget() spacer_widget.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) spacer_widget.setLayout(QHBoxLayout()) spacer_widget.layout().addItem(spacer_item) - self.checkbox_toolbar.addWidget(spacer_widget) - - checkbox_heading = QLabel(self) - checkbox_heading.setAlignment(Qt.AlignmentFlag.AlignLeft) - checkbox_heading.setText("FINDINGS:") - self.checkbox_toolbar.addWidget(checkbox_heading) - - checkbox_layout = QVBoxLayout() - for finding, checkbox in self.checkboxes.items(): - checkbox_layout.addWidget(checkbox) + layout.addWidget(spacer_widget) + + if bool(self.findings): + checkbox_heading = QLabel(self) + checkbox_heading.setAlignment(Qt.AlignmentFlag.AlignLeft) + checkbox_heading.setText("FINDINGS:") + layout.addWidget(checkbox_heading) + + checkbox_layout = QVBoxLayout() + for finding, checkbox in self.checkboxes.items(): + checkbox_layout.addWidget(checkbox) + + checkbox_layout.addItem(spacer_item) + checkbox_layout.addItem(spacer_item) + checkbox_widget = QWidget(self) + checkbox_widget.setLayout(checkbox_layout) + + layout.addWidget(checkbox_widget) + + if self.radiobutton_groups is not None: + # radiobutton_heading = QLabel(self) + # radiobutton_heading.setAlignment(Qt.AlignmentFlag.AlignLeft) + # radiobutton_heading.setText(f"For {self.task}, please rate:") + # self.labelling_toolbar.addWidget(radiobutton_heading) + for radio_group_name, radio_group_box in self.ratiobuttons_boxes.items(): + layout.addWidget(radio_group_box) + + # Add the Notes textbox + self.textbox_label.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.textbox_label.setObjectName("Notes") + self.textbox_label.setText("NOTES:") + layout.addWidget(self.textbox_label) + self.textbox.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth) + self.textbox.setMinimumHeight(150) + self.connection_manager.connect(self.textbox.textChanged, self.on_text_changed) + layout.addWidget(self.textbox) - checkbox_layout.addItem(spacer_item) - checkbox_layout.addItem(spacer_item) - checkbox_widget = QWidget(self) - checkbox_widget.setLayout(checkbox_layout) + self.labelling_toolbar.setStyleSheet("QToolBar QLabel { font-weight: bold; }") + self.addToolBar(Qt.ToolBarArea.RightToolBarArea, self.labelling_toolbar) - # Create a toolbar to hold the checkbox widget - self.checkbox_toolbar.addWidget(checkbox_widget) - self.checkbox_toolbar.setStyleSheet("QToolBar QLabel { font-weight: bold; }") - self.addToolBar(Qt.ToolBarArea.RightToolBarArea, self.checkbox_toolbar) + scroll_action = QWidgetAction(self) + scroll_action.setDefaultWidget(scroll) + self.labelling_toolbar.addAction(scroll_action) + self.addToolBar(Qt.ToolBarArea.RightToolBarArea, self.labelling_toolbar) def restore_from_saved_state(self): """ @@ -884,12 +773,17 @@ def change_image(self, direction, prev_failed=False): """ Changes the current image in the file list based on the given direction. - :param direction: str, either "previous" or "next" + :param direction: Either "previous" or "next" image :param prev_failed: bool, only applicable if direction is "next" """ if direction not in ("previous", "next"): raise ValueError("Invalid direction value. Expected 'previous' or 'next'.") + for cbox in self.findings: + # Set the checkbox value based on the stored value + checkbox_value = self.checkbox_values.get(self.file_list[self.current_index], False)[cbox] + print(cbox, checkbox_value) + # if direction == "previous": # self.viewed_values[self.file_list[self.current_index]] = True if not prev_failed: @@ -945,6 +839,7 @@ def change_image(self, direction, prev_failed=False): self.image_view.add_bboxes(self.bboxes.get(self.file_list[self.current_index], {})) self.set_checkbox_value() + self.set_checked_radiobuttons() self.viewed_label.setText(("" if self.is_image_viewed() else "NOT ") + "PREVIOUSLY VIEWED") self.viewed_icon.setPixmap( @@ -965,7 +860,8 @@ def next_image(self, prev_failed=False): """ Loads the next image in the file list. - :param prev_failed: bool, whether the image previously failed to load + :param prev_failed: Whether the image previously failed to load + :type prev_failed: bool """ self.change_image("next", prev_failed) @@ -985,13 +881,15 @@ def set_checkbox_value(self): for cbox in self.findings: # Set the checkbox value based on the stored value checkbox_value = self.checkbox_values.get(filename, False)[cbox] - self.checkboxes[cbox].setChecked(checkbox_value) + print(cbox, checkbox_value) + self.checkboxes[cbox].setCheckState(convert_to_checkstate(checkbox_value)) def keyPressEvent(self, event: QKeyEvent): """ Handles key presses as shortcuts. - :param event: QKeyEvent, the key press event + :param event: The key press event + :type event: QKeyEvent """ # Set up shortcuts # if event.modifiers() == Qt.KeyboardModifier.ControlModifier: @@ -1036,8 +934,11 @@ def save_to_json(self): saved = self.save_as() if not saved: return False + else: + return True else: self.save_json(self.json_path) + return True def save_as(self): """ @@ -1055,6 +956,7 @@ def save_as(self): self.save_json(save_path) self.settings.setValue("default_directory", file_dialog.directory().path()) self.save_settings() + return True else: return False @@ -1062,16 +964,22 @@ def save_json(self, selected_file: str): """ Saves the current outputs to a JSON file. - :param selected_file: str, the path to the file to save to + :param selected_file: Path to the file to save to + :type selected_file: str """ # Update bboxes for current file self.bboxes[self.file_list[self.current_index]] = self.image_view.rect_items data = [] for filename in self.file_list: - cbox_out = {} + viewed = self.viewed_values.get(filename, False) + rotation = self.rotation.get(filename, 0) + + notes = self.notes.get(filename, "") + + cbox_out = {} for cbox in list(self.checkboxes.keys()): # Get the checkbox values for the file if viewed != "FAILED": @@ -1088,7 +996,9 @@ def save_json(self, selected_file: str): else: bbox_out[finding] = [bbox.rect().getRect()] - notes = self.notes.get(filename, "") + radiobuttons_out = {} + for name, value in self.radiobutton_values[filename].items(): + radiobuttons_out[name] = value data.append({ 'filename': filename, @@ -1096,13 +1006,20 @@ def save_json(self, selected_file: str): 'rotation': rotation, 'notes': notes, 'checkboxes': cbox_out, - 'bboxes': bbox_out + 'bboxes': bbox_out, + 'radiobuttons': radiobuttons_out, }) with open(selected_file, 'w') as file: json.dump(data, file, indent=2) def load_from_json(self) -> bool: + """ + Loads the previous saved outputs from a JSON file. + + :return: Whether the load was successful + :rtype: bool + """ if self.settings.value("new_json", False): return False @@ -1128,6 +1045,13 @@ def load_from_json(self) -> bool: for finding, coord_sets in entry['bboxes'].items(): for coord_set in coord_sets: self.load_bounding_box(filename, finding, coord_set) + + if 'radiobuttons' in entry: + for name, value in entry['radiobuttons'].items(): + if filename in self.radiobutton_values: + self.radiobutton_values[filename][name] = value + else: + self.radiobutton_values[filename] = {name: value} return True def load_bounding_box(self, file: str, finding: str, raw_rect: Tuple[float, float, float, float]): @@ -1135,9 +1059,12 @@ def load_bounding_box(self, file: str, finding: str, raw_rect: Tuple[float, floa Loads a bounding box object from the x, y, height, width stored in the JSON file and adds it to the appropriate bboxes dictionary entry. - :param file: str, the file path of the image associated with the bounding box - :param finding: str, the finding type associated with the bounding box - :param raw_rect: tuple of (float, float, float, float), the (x, y, width, height) values of the bounding box + :param file: File path of the image associated with the bounding box + :type file: str + :param finding: The finding type associated with the bounding box + :type finding: str + :param raw_rect: The (x, y, width, height) values of the bounding box + :type raw_rect: Tuple[float, float, float, float] """ bbx, bby, bbw, bbh = raw_rect color = self.colors[finding] @@ -1152,7 +1079,8 @@ def on_checkbox_changed(self, state: int): Updates the checkbox values when a checkbox is changed, updates the cursor mode, and sets the current finding in the image view based on the checkbox state. - :param state: int, the state of the checkbox (Qt.CheckState.Unchecked or Qt.CheckState.Checked) + :param state: The state of the checkbox (Qt.CheckState.Unchecked or Qt.CheckState.Checked) + :type state: int """ filename = self.file_list[self.current_index] cbox = self.sender().text() @@ -1186,7 +1114,8 @@ def closeEvent(self, event: QCloseEvent): """ Handles the close event. - :param event: QCloseEvent, the close event + :param event: The close event + :type event: QCloseEvent """ # Ask the user if they want to save before closing @@ -1196,6 +1125,8 @@ def closeEvent(self, event: QCloseEvent): icon_label.setPixmap(self.icons['question'].pixmap(64, 64)) close_msg_box.setIconPixmap(icon_label.pixmap()) + self.settings.setValue('window_size', self.size()) + close_msg_box.setText("Save Changes?") close_msg_box.setInformativeText("Do you want to save changes before closing?") close_msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | diff --git a/speedy_qc/utils.py b/speedy_qc/utils.py index c52c46e..1feb566 100644 --- a/speedy_qc/utils.py +++ b/speedy_qc/utils.py @@ -10,26 +10,25 @@ 3. Logging setup. 4. Connection management for signals and slots in a Qt application. +Classes: + Connection + ConnectionManager + Functions: create_default_config() -> dict open_yml_file(config_path: str) -> dict setup_logging(log_out_path: str) -> Tuple[logging.Logger, logging.Logger] - -Classes: - Connection - ConnectionManager + bytescale(data: np.ndarray, cmin: int = None, cmax: int = None, high: int = 255, low: int = 0) -> np.ndarray + convert_to_checkstate(value: Any) -> Qt.CheckState """ import logging.config import yaml import os -from typing import Dict, Tuple, Any +from typing import Dict, Tuple, Any, Optional from PyQt6.QtCore import * -from PyQt6.QtGui import * -from PyQt6.QtWidgets import * import sys -from qt_material import get_theme - +import numpy as np if hasattr(sys, '_MEIPASS'): # This is a py2app executable @@ -40,6 +39,50 @@ else: resource_dir = os.path.join(os.path.dirname(os.path.abspath("__main__")), 'speedy_qc') + +class Connection: + """ + A class to manage a single connection between a signal and a slot in a Qt application. + """ + def __init__(self, signal: pyqtSignal, slot: callable): + self.signal = signal + self.slot = slot + self.connection = self.signal.connect(self.slot) + + def disconnect(self): + """ + Disconnects the signal from the slot. + """ + self.signal.disconnect(self.slot) + + +class ConnectionManager: + """ + A class to manage multiple connections between signals and slots in a Qt application. + """ + def __init__(self): + self.connections = {} + + def connect(self, signal: Any, slot: callable): + """ + Connects a signal to a slot and stores the connection in a dictionary. + + :param signal: QtCore.pyqtSignal, the signal to connect. + :param slot: callable, the slot (function or method) to connect to the signal. + """ + connection = Connection(signal, slot) + self.connections[id(connection)] = connection + + def disconnect_all(self): + """ + Disconnects all connections and clears the dictionary. + """ + for connection in self.connections.values(): + if isinstance(connection, Connection): + connection.disconnect() + self.connections = {} + + def create_default_config() -> Dict: """ Creates a default config file in the speedy_qc directory. @@ -49,6 +92,7 @@ def create_default_config() -> Dict: # Default config... default_config = { 'checkboxes': ['QC1', 'QC2', 'QC3', 'QC4', 'QC5'], + 'radiobuttons': [{'title': "Radiobuttons", 'labels': [1, 2, 3, 4]}, ], 'max_backups': 10, 'backup_dir': os.path.expanduser('~/speedy_qc/backups'), 'log_dir': os.path.expanduser('~/speedy_qc/logs'), @@ -64,6 +108,7 @@ def create_default_config() -> Dict: return default_config + def open_yml_file(config_path: str) -> Dict: """ Opens a config .yml file and returns the data. If the file does not exist, it will look @@ -115,45 +160,63 @@ def setup_logging(log_out_path: str) -> Tuple[logging.Logger, logging.Logger]: console_msg = logging.getLogger(__name__) return logger, console_msg -class Connection: + +def bytescale( + arr: np.ndarray, + low: Optional[float] = None, + high: Optional[float] = None, + a: float = 0, + b: float = 255 +) -> np.ndarray: """ - A class to manage a single connection between a signal and a slot in a Qt application. + Linearly rescale values in an array. By default, it scales the values to the byte range (0-255). + + :param arr: The array to rescale. + :type arr: np.ndarray + :param low: Lower boundary of the output interval. All values smaller than low are clipped to low. + :type low: float + :param high: Upper boundary of the output interval. All values larger than high are clipped to high. + :type high: float + :param a: Lower boundary of the input interval. + :type a: float + :param b: Upper boundary of the input interval. + :type b: float + :return: The rescaled array. + :rtype: np.ndarray """ - def __init__(self, signal: pyqtSignal, slot: callable): - self.signal = signal - self.slot = slot - self.connection = self.signal.connect(self.slot) - def disconnect(self): - """ - Disconnects the signal from the slot. - """ - self.signal.disconnect(self.slot) + arr = arr.astype(float) # to ensure floating point division + # Clip to specified high/low values, if any + if low is not None: + arr = np.maximum(arr, low) + if high is not None: + arr = np.minimum(arr, high) -class ConnectionManager: - """ - A class to manage multiple connections between signals and slots in a Qt application. - """ - def __init__(self): - self.connections = {} + min_val, max_val = np.min(arr), np.max(arr) - def connect(self, signal: Any, slot: callable): - """ - Connects a signal to a slot and stores the connection in a dictionary. + if np.isclose(min_val, max_val): # avoid division by zero + return np.full_like(arr, a, dtype=np.uint8) - :param signal: QtCore.pyqtSignal, the signal to connect. - :param slot: callable, the slot (function or method) to connect to the signal. - """ - connection = Connection(signal, slot) - self.connections[id(connection)] = connection + # Normalize between a and b + return (((b - a) * (arr - min_val) / (max_val - min_val)) + a).astype(np.uint8) - def disconnect_all(self): - """ - Disconnects all connections and clears the dictionary. - """ - for connection in self.connections.values(): - if isinstance(connection, Connection): - connection.disconnect() - self.connections = {} +def convert_to_checkstate(value: int) -> Qt.CheckState: + """ + Converts an integer value to a Qt.CheckState value for tri-state checkboxes. + + :param value: int, the value to convert. + :type: int + :return: The converted value. + :rtype: Qt.CheckState + """ + if value == 0: + return Qt.CheckState.Unchecked + elif value == 1: + return Qt.CheckState.PartiallyChecked + elif value == 2: + return Qt.CheckState.Checked + else: + # Handle invalid values or default case + return Qt.CheckState.Unchecked diff --git a/speedy_qc/custom_windows.py b/speedy_qc/windows.py similarity index 80% rename from speedy_qc/custom_windows.py rename to speedy_qc/windows.py index bacd83e..2d4479f 100644 --- a/speedy_qc/custom_windows.py +++ b/speedy_qc/windows.py @@ -1,5 +1,5 @@ """ -custom_windows.py +windows.py This module contains custom QDialog classes used for displaying specific dialogs in the application. These dialogs include the initial dialog box for loading a configuration file and the 'About' dialog box @@ -9,7 +9,7 @@ - LoadMessageBox: A custom QDialog for selecting a configuration file when launching the application. - AboutMessageBox: A custom QDialog for displaying information about the application and its license. - SetupWindow: A custom QDialog for displaying the setup window when the application is first launched to allow - the user to select the dicom directory and decide whether to continue previous progress by + the user to select the image directory and decide whether to continue previous progress by loading an existing json file. Functions: @@ -17,11 +17,10 @@ """ import os -import logging from PyQt6.QtCore import * from PyQt6.QtGui import * from PyQt6.QtWidgets import * -from typing import Optional +from typing import Optional, List import sys import json @@ -41,7 +40,6 @@ config_data = open_yml_file(os.path.join(resource_dir, config_file)) logger, console_msg = setup_logging(config_data['log_dir']) - if hasattr(sys, '_MEIPASS'): # This is a py2app executable resource_dir = sys._MEIPASS @@ -51,15 +49,20 @@ else: resource_dir = os.path.join(os.path.dirname(os.path.abspath("__main__")), 'speedy_qc') + class AboutMessageBox(QDialog): """ A custom QDialog for displaying information about the application from the About option in the menu. + + :param parent: QWidget or None, the parent widget of this QDialog (default: None). + :type parent: QWidget or None """ def __init__(self, parent: Optional[QWidget] = None): """ Initialize the AboutMessageBox. :param parent: QWidget or None, the parent widget of this QDialog (default: None). + :type parent: QWidget or None """ super().__init__(parent) # self.connections = {} @@ -164,12 +167,16 @@ class LoadMessageBox(QDialog): The initial dialog box that appears when the application is launched. This dialog box allows the user to select the config file to load into the application and allows them to launch the configuration wizard to customise Speedy QC. + + :param parent: QWidget or None, the parent widget of this QDialog (default: None). + :type parent: QWidget or None """ def __init__(self, parent: Optional[QWidget] = None): """ Initialize the LoadMessageBox. :param parent: QWidget or None, the parent widget of this QDialog (default: None). + :type parent: QWidget or None """ super().__init__(parent) # self.connections = {} @@ -252,7 +259,7 @@ def __init__(self, parent: Optional[QWidget] = None): right_layout.addItem(spacer) # Create a QLabel to display a prompt to the user for the following dialog - sub_text2 = QLabel("In the next window, please select a directory to\nload the DICOM files...") + sub_text2 = QLabel("In the next window, please select a directory to\nload the image files...") sub_text2.setStyleSheet("font-size: 14px;") sub_text2.setAlignment(Qt.AlignmentFlag.AlignTop) right_layout.addWidget(sub_text2) @@ -302,8 +309,9 @@ def exec(self) -> int: """ Overwrite the exec method to return a custom return code for the configuration wizard. - :return: int, 1 if the user clicks "OK", 0 if the user clicks "Cancel", 42 if the user - clicks "Configuration Wizard" + :return: 1 if the user clicks "OK", 0 if the user clicks "Cancel", 42 if the user + clicks "Configuration Wizard" + :rtype: int """ result = super().exec() try: @@ -313,17 +321,44 @@ def exec(self) -> int: return 1 else: return 0 - def save_last_config(self, config_file: str): + + def save_last_config(self, conf_name: str): """ Save the selected config file to QSettings + + :param conf_name: The name of the selected config file + :type conf_name: str """ # Save the selected config file to QSettings - print(f"Selected config file: {config_file}") - self.settings.setValue("last_config_file", os.path.join(resource_dir, config_file)) + print(f"Selected config file: {conf_name}") + self.settings.setValue("last_config_file", os.path.join(resource_dir, conf_name)) + + def closeEvent(self, event: QCloseEvent): + """ + Handles a close event and disconnects connections between signals and slots. + + :param event: The close event + :type event: QCloseEvent + """ + self.connection_manager.disconnect_all() + event.accept() class SetupWindow(QDialog): - def __init__(self, settings): + """ + A QDialog window for setting up Speedy QC for Desktop, including chosing a directory of images to load and + selecting a json file to continue previous labelling. + + :param settings: A QSettings object for storing settings + :type settings: QSettings + """ + def __init__(self, settings: QSettings): + """ + Initialise the SetupWindow. + + :param settings: A QSettings object for storing settings + :type settings: QSettings + """ super().__init__() # Set up UI elements @@ -331,7 +366,7 @@ def __init__(self, settings): self.connection_manager = ConnectionManager() self.folder_label = QLabel() self.json_label = QLabel() - self.folder_label.setText(self.settings.value("dicom_path", "")) + self.folder_label.setText(self.settings.value("image_path", "")) self.json_label.setText(self.settings.value("json_path", "")) self.folder_button = QPushButton("...") self.json_button = QPushButton("...") @@ -377,14 +412,14 @@ def __init__(self, settings): layout.addSpacerItem(expanding_spacer) dcm_layout = QVBoxLayout() - dcm_info_label = QLabel("Please select the folder containing the DICOM images:") + dcm_info_label = QLabel("Please select the folder containing the images:") dcm_info_label.setStyleSheet("font-weight: bold;") dcm_layout.addWidget(dcm_info_label) dcm_layout.addSpacerItem(fixed_spacer) dcm_selection_layout = QHBoxLayout() - dcm_selection_layout.addWidget(QLabel("Selected DICOM Folder:")) + dcm_selection_layout.addWidget(QLabel("Selected Image Folder:")) dcm_selection_layout.addSpacerItem(expanding_spacer) dcm_selection_layout.addWidget(self.folder_label) dcm_selection_layout.addWidget(self.folder_button) @@ -445,7 +480,7 @@ def __init__(self, settings): # Connect buttons to functions self.connection_manager.connect(self.json_button.clicked, self.select_json) - self.connection_manager.connect(self.folder_button.clicked, self.select_dcm_folder) + self.connection_manager.connect(self.folder_button.clicked, self.select_image_folder) self.connection_manager.connect(self.new_json_tickbox.stateChanged, self.on_json_checkbox_changed) @@ -458,7 +493,7 @@ def on_accepted(self): """ if not os.path.isdir(self.folder_label.text()): - self.generate_no_dcm_msg() + self.generate_no_image_msg() self.button_box.button(QDialogButtonBox.Ok).setEnabled(False) return elif self.new_json_tickbox.isChecked(): @@ -494,15 +529,16 @@ def on_json_checkbox_changed(self): else: self.button_box.button(QDialogButtonBox.Ok).setEnabled(True) - def load_saved_files(self, settings): + def load_saved_files(self, settings: QSettings): """ Load previously selected files from QSettings. :param settings: QSettings object + :type settings: QSettings """ # Get saved file paths from QSettings json_path = settings.value("json_path", "") - folder_path = settings.value("dicom_path", "") + folder_path = settings.value("image_path", "") # Update labels with saved file paths if json_path: @@ -510,17 +546,20 @@ def load_saved_files(self, settings): if folder_path: self.folder_label.setText(folder_path) - def save_file_paths(self, settings, json_path, folder_path): + def save_file_paths(self, settings: str, json_path: str, folder_path: str): """ Update QSettings :param settings: QSettings object to update + :type settings: QSettings :param json_path: Path to JSON file - :param folder_path: Path to DICOM folder + :type json_path: str + :param folder_path: Path to image folder + :type folder_path: str """ # Save file paths to QSettings settings.setValue("json_path", json_path) - settings.setValue("dicom_path", folder_path) + settings.setValue("image_path", folder_path) def select_json(self): """ @@ -535,29 +574,31 @@ def select_json(self): self.save_file_paths(self.settings, json_path, self.folder_label.text()) self.button_box.button(QDialogButtonBox.Ok).setEnabled(True) - def select_dcm_folder(self): + def select_image_folder(self): """ - Open file dialog to select DICOM folder. Only accept if directory contains a dicom file. + Open file dialog to select image folder. Only accept if directory contains an image file. """ - dicom_dir = None - while dicom_dir is None: + image_dir = None + while image_dir is None: # Open file dialog to select image folder - folder_path = QFileDialog.getExistingDirectory(self, "Select DICOM Folder", self.folder_label.text()) + folder_path = QFileDialog.getExistingDirectory(self, "Select Image Folder", self.folder_label.text()) # Update label and save file path if folder_path: - dcm_files = [f for f in os.listdir(folder_path) if f.endswith('.dcm')] - if len(dcm_files) == 0: + img_files = [f for f in os.listdir(folder_path) if f.endswith(( + '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif', '.dcm', '.dicom', + ))] + if len(img_files) == 0: error_msg_box = QMessageBox() error_msg_box.setIcon(QMessageBox.Icon.Warning) error_msg_box.setWindowTitle("Error") - error_msg_box.setText("The directory does not appear to contain any dicom files!") + error_msg_box.setText("The directory does not appear to contain any image files!") error_msg_box.setInformativeText("Please try again.") error_msg_box.exec() else: self.folder_label.setText(folder_path) self.save_file_paths(self.settings, self.json_label.text(), folder_path) - dicom_dir = folder_path + image_dir = folder_path self.button_box.button(QDialogButtonBox.Ok).setEnabled(True) if not self.new_json_tickbox.isChecked(): self.check_json_compatibility(self.json_label.text()) @@ -586,43 +627,65 @@ def generate_json_cbox_incompatibility_msg(self): QMessageBox.critical(self, "Error", f"JSON - CONFIG FILE CONFLICT!\n\n" - f"The selected json file has checkbox names which are not in the config file.\n\n" + f"The selected json file has checkbox name/s which are not in the config file.\n\n" f"Please select a new json file or start again and select a new config file. ", QMessageBox.StandardButton.Ok, defaultButton=QMessageBox.StandardButton.Ok) - def generate_json_dcm_incompatibility_msg(self): + def generate_json_rb_incompatibility_msg(self): """ - Generate a message box to inform the user that the selected json file is incompatible with the DICOM folder - as it contains DICOM filenames which are not present in the folder. + Generate a message box to inform the user that the selected json file is incompatible with the config file + due to different checkbox names. + """ + QMessageBox.critical(self, + "Error", + f"JSON - CONFIG FILE CONFLICT!\n\n" + f"The selected json file has radiobutton group/s which are not in the config file.\n\n" + f"Please select a new json file or start again and select a new config file. ", + QMessageBox.StandardButton.Ok, + defaultButton=QMessageBox.StandardButton.Ok) + + def generate_json_image_incompatibility_msg(self): + """ + Generate a message box to inform the user that the selected json file is incompatible with the image folder + as it contains image filenames which are not present in the folder. """ QMessageBox.critical(self, "Error", - f"JSON - DICOM FOLDER CONFLICT!\n\n" - f"The selected json file has image files which are not present in the selected DICOM " + f"JSON - IMAGE FOLDER CONFLICT!\n\n" + f"The selected json file has image files which are not present in the selected image " f"directory.\n\n" - f"Please select a new json file or DICOM directory. Alternatively, start again and select " + f"Please select a new json file or image directory. Alternatively, start again and select " f"a new config file. ", QMessageBox.StandardButton.Ok, defaultButton=QMessageBox.StandardButton.Ok) - def generate_no_dcm_msg(self): + def generate_no_image_msg(self): """ Generate a message box to inform the user that no dcm folder is selected. """ QMessageBox.critical(self, "Error", - f"NO DICOM DIRECTORY SELECTED!\n\n" - f"Please select a dicom directory or start again and select a new config file. ", + f"NO IMAGE DIRECTORY SELECTED!\n\n" + f"Please select an image directory or start again and select a new config file. ", QMessageBox.StandardButton.Ok, defaultButton=QMessageBox.StandardButton.Ok) - def check_config_json_compatibility(self, cboxes, cbox_values): + def check_config_json_compatibility(self, cboxes: List[str], cbox_values: List[int], rbs: [str]) -> bool: """ Check if the selected json file is compatible with the config file. + + :param cboxes: list of checkbox names + :type cboxes: list + :param cbox_values: list of checkbox values + :type cbox_values: list + :param rbs: list of radiobutton group names + :type rbs: list + :return: True if compatible, False otherwise + :rtype: bool """ - if not self.config['tristate_cboxes']: + if not self.config['tristate_checkboxes']: if 1 in cbox_values: self.generate_json_tristate_incompatibility_msg() return False @@ -632,36 +695,54 @@ def check_config_json_compatibility(self, cboxes, cbox_values): self.generate_json_cbox_incompatibility_msg() return False + config_rb_names = [group['title'] for group in self.config['radiobuttons']] + for rb in rbs: + if rb not in config_rb_names: + self.generate_json_rb_incompatibility_msg() + return False + return True - def check_json_dicom_compatibility(self, filenames): + def check_json_image_compatibility(self, filenames: List[str]) -> bool: """ - Check if the selected json file is compatible with the DICOM folder. + Check if the selected json file is compatible with the image folder. + + :param filenames: list of image filenames in the json file + :type filenames: list + :return: True if compatible, False otherwise + :rtype: bool """ if os.path.isdir(self.folder_label.text()): - dcms = os.listdir(self.folder_label.text()) + imgs = sorted([f for f in os.listdir(self.folder_label.text()) if f.endswith(( + '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif', '.dcm', '.dicom', + ))]) # Get list of dcms in json for file in filenames: - if file not in dcms: - self.generate_json_dcm_incompatibility_msg() + if file not in imgs: + self.generate_json_image_incompatibility_msg() return False return True else: - self.generate_no_dcm_msg() + self.generate_no_image_msg() return False - def check_json_compatibility(self, json_path): + def check_json_compatibility(self, json_path: str) -> bool: """ - Check if the selected json file is compatible with the DICOM files in the image directory and the selected + Check if the selected json file is compatible with the image files in the image directory and the selected config yml file. This prevents the program from crashing if incompatible files are selected. + + :param json_path: path to the json file + :type json_path: str + :return: True if compatible, False otherwise + :rtype: bool """ if os.path.isfile(json_path): - filenames, cboxes, cbox_values = load_json_filenames_findings(json_path) - dcm_compatible = self.check_json_dicom_compatibility(filenames) + filenames, cboxes, cbox_values, rbs = load_json_filenames_findings(json_path) + dcm_compatible = self.check_json_image_compatibility(filenames) if dcm_compatible: - config_compatible = self.check_config_json_compatibility(cboxes, cbox_values) + config_compatible = self.check_config_json_compatibility(cboxes, cbox_values, rbs) if config_compatible: self.button_box.button(QDialogButtonBox.Ok).setEnabled(True) return True @@ -675,13 +756,20 @@ def check_json_compatibility(self, json_path): def closeEvent(self, event: QCloseEvent): """ Handles a close event and disconnects connections between signals and slots. + + :param event: close event + :type event: QCloseEvent """ self.connection_manager.disconnect_all() event.accept() -def load_json_filenames_findings(json_path): + +def load_json_filenames_findings(json_path: str): """ Load the filenames and findings from a json file. + + :param json_path: path to the json file + :type json_path: str """ with open(json_path, 'r') as file: @@ -690,7 +778,10 @@ def load_json_filenames_findings(json_path): filenames = [entry['filename'] for entry in data] cboxes = [cbox for entry in data if 'checkboxes' in entry for cbox in entry['checkboxes'].keys()] cbox_values = [value for entry in data if 'checkboxes' in entry for value in entry['checkboxes'].values()] + radiobs = [rb for entry in data if 'radiobuttons' in entry for rb in entry['radiobuttons'].keys()] + unique_cboxes = sorted(list(set(cboxes))) unique_cbox_values = sorted(list(set(cbox_values))) + unique_radiobs = sorted(list(set(radiobs))) - return filenames, unique_cboxes, unique_cbox_values + return filenames, unique_cboxes, unique_cbox_values, unique_radiobs diff --git a/speedy_qc/wizard.py b/speedy_qc/wizard.py new file mode 100644 index 0000000..09d6827 --- /dev/null +++ b/speedy_qc/wizard.py @@ -0,0 +1,676 @@ +""" +wizard.py + +This module provides a configuration wizard for the Speedy QC application, allowing users +to customize various settings such as checkbox labels, maximum number of backup files, +and directories for backup and log files. The wizard can be run from the initial dialog +box of the application, from the command line, or from Python. + +Classes: + - RadioButtonPage: A QWizardPage class implementation for selecting a radio button. + - RadioButtonGroupDialog: A QDialog class implementation for selecting a radio button. + - ConfigurationWizard: A QWizard class implementation to guide users through the process of + customizing the application configuration. +""" + +from PyQt6.QtCore import * +from PyQt6.QtGui import * +from PyQt6.QtWidgets import * +import yaml +import os +from qt_material import apply_stylesheet, get_theme +import sys +from math import ceil + +from speedy_qc.utils import open_yml_file, setup_logging, ConnectionManager + +if hasattr(sys, '_MEIPASS'): + # This is a py2app executable + resource_dir = sys._MEIPASS +elif 'main.py' in os.listdir(os.path.dirname(os.path.abspath("__main__"))): + # This is a regular Python script + resource_dir = os.path.dirname(os.path.abspath("__main__")) +else: + resource_dir = os.path.join(os.path.dirname(os.path.abspath("__main__")), 'speedy_qc') + + +class RadioButtonPage(QWizardPage): + """ + A QWizardPage class implementation for adding radio button groups to the configuration. + + :param parent: The parent widget. + :type parent: QWidget + """ + def __init__(self, parent=None): + """ + Initializes the page. + + :param parent: The parent widget. + :type parent: QWidget + """ + super().__init__(parent) + + self.connection_manager = ConnectionManager() + + self.setTitle("Radio Button Page") + self.setSubTitle("\nPlease specify the radio button groups you want to add...\n") + + self.layout = QVBoxLayout(self) + + # Create a scroll area + self.scrollArea = QScrollArea(self) + + # Create a widget that will contain the radio button groups + self.scrollWidget = QWidget() + self.scrollLayout = QVBoxLayout() + self.scrollWidget.setLayout(self.scrollLayout) + + # Set the widget as the widget of the scroll area + self.scrollArea.setWidget(self.scrollWidget) + self.scrollArea.setWidgetResizable(True) + + self.addButton = QPushButton("Add Radio Button Group") + self.connection_manager.connect(self.addButton.clicked, self.add_group) + + # Add the scroll area and button to the main layout + self.layout.addWidget(self.addButton) + self.layout.addWidget(self.scrollArea) + + self.radio_groups = [] # Track the radio button groups + + def add_group(self): + """ + Adds a radio button group to the page. + """ + dialog = RadioButtonGroupDialog(self) + if dialog.exec() == QDialog.Accepted: + group = QGroupBox(dialog.title) + layout = QGridLayout() # Use QGridLayout for multiple columns + + max_label_length = max([len(str(label)) for label in dialog.labels]) + num_columns = ceil(10 / max_label_length) # Adjust the number of columns based on the label length + + for i, text in enumerate(dialog.labels): + radio_button = QRadioButton(str(text)) + row = i // num_columns + col = i % num_columns + layout.addWidget(radio_button, row, col) + + # Adding a remove button + removeButton = QPushButton("Remove Group") + self.connection_manager.connect(removeButton.clicked, lambda: self.remove_group(group)) + layout.addWidget(removeButton, ceil(len(dialog.labels) / num_columns), 0, 1, num_columns) # Span the remove button across all columns + + group.setLayout(layout) + self.scrollLayout.addWidget(group) + + # Add the group to the radio_groups list + self.radio_groups.append({ + 'group': group, + 'title': dialog.title, + 'labels': dialog.labels + }) + + def remove_group(self, group): + """ + Removes a radio button group from the page. + """ + # Find the group entry in radio_groups and remove it + for radio_group in self.radio_groups: + if radio_group['group'] is group: + self.scrollLayout.removeWidget(group) + group.setParent(None) + group.deleteLater() + self.radio_groups.remove(radio_group) + break + + def get_group_data(self) -> list: + """ + Returns the data for the radio button groups. + + :return: The data for the radio button groups. + :rtype: list + """ + group_data = [] + for radio_group in self.radio_groups: + group_data.append({ + 'title': radio_group['title'], + 'labels': radio_group['labels'] + }) + return group_data + + def add_group_without_dialog(self, title, labels): + """ + Adds a radio button group to the page without opening a dialog. + + :param title: The title of the group. + :type title: str + :param labels: The labels of the radio buttons. + :type labels: list + """ + group = QGroupBox(title) + layout = QGridLayout() + + max_label_length = max(len(str(label)) for label in labels) + num_columns = ceil(10 / max_label_length) + + for i, text in enumerate(labels): + radio_button = QRadioButton(str(text)) + row = i // num_columns + col = i % num_columns + layout.addWidget(radio_button, row, col) + + remove_button = QPushButton("Remove Group") + self.connection_manager.connect(remove_button.clicked, lambda: self.remove_group(group)) + layout.addWidget(remove_button, ceil(len(labels) / num_columns), 0, 1, num_columns) + + group.setLayout(layout) + self.scrollLayout.addWidget(group) + + self.radio_groups.append({ + 'group': group, + 'title': title, + 'labels': labels + }) + + def load_group_data(self, group_data): + """ + Loads the radio button group data. + + :param group_data: The radio button group data. + :type group_data: list + """ + for group in group_data: + self.add_group_without_dialog(group['title'], group['labels']) + + +class RadioButtonGroupDialog(QDialog): + """ + A QDialog class implementation for adding radio button groups to the configuration. + + :param parent: The parent widget. + :type parent: QWidget + """ + def __init__(self, parent=None): + """ + Initializes the dialog. + + :param parent: The parent widget. + :type parent: QWidget + """ + super().__init__(parent) + + self.connection_manager = ConnectionManager() + + self.setWindowTitle("New Radio Button Group") + + self.titleInput = QLineEdit(self) + self.labelsInput = QTextEdit(self) + self.labelsInput.setPlaceholderText("Enter one label per line") + + self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self) + self.connection_manager.connect(self.buttons.accepted, self.accept) + self.connection_manager.connect(self.buttons.rejected, self.reject) + + layout = QVBoxLayout(self) + layout.addWidget(QLabel("Group Title:")) + layout.addWidget(self.titleInput) + layout.addWidget(QLabel("Button Labels:")) + layout.addWidget(self.labelsInput) + layout.addWidget(self.buttons) + + @property + def title(self) -> str: + """ + The title of the radio button group. + """ + return self.titleInput.text() + + @property + def labels(self) -> list: + """ + The labels of the radio buttons. + """ + return self.labelsInput.toPlainText().split("\n") + + +class ConfigurationWizard(QWizard): + """ + A QWizard implementation for customizing the configuration of the Speedy QC application. + Allows users to customize checkbox labels, maximum number of backup files, and directories + for backup and log files. Can be run from the initial dialog box, from the command line, + or from Python. + + Methods: + - create_label_page: Creates the first page of the wizard, allowing users to customize + the labels of the checkboxes. + - create_backup_page: Creates the second page of the wizard, allowing users to customize + the maximum number of backup files and the directories for backup + and log files. + - add_label: Adds a new label to the label page for a new checkbox/finding. + - create_save_page: Creates the third page of the wizard, allowing users to save the + configuration to a .yml file. + - update_combobox_stylesheet: Updates the stylesheet of the QComboBoxes in the label page + to make the options more visible. + - update_combobox_state: Updates the QComboBox on the save page with the list of existing .yml files. + - accept: Saves the configuration to a .yml file and closes the wizard. + """ + def __init__(self, config_path: str): + """ + Initializes the wizard. + + :param config_path: The path to the configuration file. + :type config_path: str + """ + super().__init__() + self.settings = QSettings('SpeedyQC', 'DicomViewer') + self.connection_manager = ConnectionManager() + + self.setStyleSheet(f""" + QLineEdit {{ + color: {get_theme('dark_blue.xml')['primaryLightColor']}; + }} + QSpinBox {{ + color: {get_theme('dark_blue.xml')['primaryLightColor']}; + }} + QComboBox {{ + color: {get_theme('dark_blue.xml')['primaryLightColor']}; + }} + """) + + # Set the wizard style to have the title and icon at the top + self.setWizardStyle(QWizard.WizardStyle.ModernStyle) + + self.config_path = config_path + self.config_data = None + + # Enable IndependentPages option + self.setOption(QWizard.WizardOption.IndependentPages, True) + + # Set the logo pixmap + + icon_path = os.path.join(resource_dir, 'assets/3x/white_panel@3x.png') + pixmap = QPixmap(icon_path) + self.setPixmap(QWizard.WizardPixmap.LogoPixmap, pixmap.scaled(320, 320, Qt.AspectRatioMode.KeepAspectRatio)) + + # Load the config file + self.config_data = open_yml_file(self.config_path) + self.checkboxes = self.config_data.get('checkboxes', []) + self.radio_buttons = self.config_data.get('radiobuttons', []) + self.max_backups = self.config_data.get('max_backups', 10) + self.backup_interval = self.config_data.get('backup_interval', 5) + self.backup_dir = self.config_data.get('backup_dir', os.path.expanduser('~/speedy_qc/backups')) + self.log_dir = self.config_data.get('log_dir', os.path.expanduser('~/speedy_qc/logs')) + self.tristate_checkboxes = bool(self.config_data.get('tristate_checkboxes', False)) + + self.input_option_checkboxes = {} + # Create pages for the wizard + self.options_page = self.create_options_page() + self.label_page = self.create_label_page() + self.radio_page = self.create_radio_page() + self.backup_page = self.create_backup_page() + self.save_page = self.create_save_page() + + self.radio_page.load_group_data(self.radio_buttons) + + # Set up the wizard + self.addPage(self.options_page) + self.addPage(self.label_page) + self.addPage(self.radio_page) + self.addPage(self.backup_page) + self.addPage(self.save_page) + + # Set the window title and modality + self.setWindowTitle("Speedy QC Configuration Wizard") + self.setWindowModality(Qt.WindowModality.ApplicationModal) + + # Set the size of the wizard to allow for list of checkboxes to fit nicely + self.resize(700, 800) + + # Set the default button to be the next / finish button + next_button = self.button(QWizard.NextButton) + next_button.setDefault(True) + + def create_options_page(self): + """ + Creates the first page of the wizard, allowing users to select which types of input + they want to use (checkboxes, radio buttons, or both). + + :return: The first page of the wizard. + :rtype: QWizardPage + """ + page = QWizardPage() + page.setTitle("Checkbox Page") + page.setSubTitle("\nPlease select which types of input you want to use...\n") + + layout = QVBoxLayout(page) + for input_type in ["Checkbox", "Radio Buttons"]: + cbox = QCheckBox(input_type) + layout.addWidget(cbox) + self.input_option_checkboxes[input_type] = cbox + layout.addWidget(cbox) + + self.input_option_checkboxes["Checkbox"].setChecked(bool(self.checkboxes)) + self.input_option_checkboxes["Radio Buttons"].setChecked(bool(self.radio_buttons)) + + layout.setAlignment(Qt.AlignmentFlag.AlignHCenter) + layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + return page + + def nextId(self) -> int: + """ + This method is used to control the order of the pages. + + :return: The ID of the next page to go to. + :rtype: int + """ + cboxes = bool(self.input_option_checkboxes["Checkbox"].isChecked()) + radio = bool(self.input_option_checkboxes["Radio Buttons"].isChecked()) + + current_id = self.currentId() + if current_id == 0: # If we are on the options page... + if cboxes and not radio: + return 1 # Go to the checkboxes page + elif not cboxes and radio: + return 2 # Go to the radio buttons page + elif cboxes and radio: + return 1 # Go to the checkboxes page first + else: # If both checkboxes are not checked... + return 0 # Go back to the options page + + elif current_id == 1: # If we are on the checkboxes page... + if radio: + return 2 # Go to the radio buttons page + else: + return 3 # Skip to the backup page + + # If we are not on the first or second page, let QWizard handle the page order normally. + else: + return super().nextId() + + def create_label_page(self): + """ + Creates the page of the wizard that allows users to name the checkboxes. + + :return: The page of the wizard that allows users to name the checkboxes. + :rtype: QWizardPage + """ + page = QWizardPage() + page.setTitle("Checkbox Labels") + page.setSubTitle("\nPlease name the checkboxes to label the images...\n") + + layout = QVBoxLayout(page) + + cbox_layout = QHBoxLayout() + self.tristate_checkbox = QCheckBox("Use tri-state checkboxes, i.e. have third uncertain option") + self.tristate_checkbox.setChecked(bool(self.config_data.get('tristate_checkboxes', False))) + self.connection_manager.connect(self.tristate_checkbox.stateChanged, self.update_tristate_checkboxes_state) + cbox_layout.addWidget(self.tristate_checkbox) + cbox_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter) + cbox_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + layout.addLayout(cbox_layout) + + self.labels_widget = QWidget(page) + self.labels_layout = QVBoxLayout(self.labels_widget) + self.labels_layout.setAlignment(Qt.AlignmentFlag.AlignTop) # Align to the top + + for label in self.checkboxes: + self.add_label(label) + + self.add_label_button = QPushButton("Add Label") + self.connection_manager.connect(self.add_label_button.clicked, lambda: self.add_label()) + + # Create a QScrollArea + self.scroll_area = QScrollArea() + self.scroll_area.setWidgetResizable(True) # The contents will resize to fill the scroll area + self.scroll_area.setWidget(self.labels_widget) + + layout.addWidget(self.scroll_area) + layout.addWidget(self.add_label_button) + + return page + + def add_label(self, label_text=""): + """ + Adds a label to the list of labels. + """ + line_edit = QLineEdit(label_text) + remove_button = QPushButton("Remove") + remove_button.setFixedSize(100, 40) + + self.connection_manager.connect(remove_button.clicked, lambda: self.remove_label(line_edit, remove_button)) + + # Create a horizontal layout for the line edit and the remove button + hbox = QHBoxLayout() + hbox.addWidget(line_edit) + hbox.addWidget(remove_button) + self.labels_layout.addLayout(hbox) + + def remove_label(self, line_edit, button): + """ + Removes a label from the list of labels. + """ + # Get the layout that contains the line edit and the button + hbox = line_edit.parent().layout() + + # Remove the line edit and the button from the layout + hbox.removeWidget(line_edit) + hbox.removeWidget(button) + + # Delete the line edit and the button + line_edit.deleteLater() + button.deleteLater() + + @staticmethod + def create_radio_page() -> QWizardPage: + """ + Creates the page of the wizard that allows users to name the radio buttons. + + :return: The page of the wizard that allows users to name the radio buttons. + :rtype: QWizardPage + """ + page = RadioButtonPage() + return page + + def update_tristate_checkboxes_state(self, state): + """ + Updates the state of the tristate_checkboxes option in the config file. + + :param state: The state of the tristate_checkboxes option. + :type state: int + """ + self.tristate_checkboxes = bool(state) + + def create_backup_page(self) -> QWizardPage: + """ + Creates the page for the wizard to customise the backup and log directories, and + the maximum number of backup files. + + :return: The page for the wizard to customise the backup and log directories, and + the maximum number of backup files. + :rtype: QWizardPage + """ + + page = QWizardPage() + page.setTitle("Logging and Backup Files") + page.setSubTitle("\nPlease choose where logs and backups should be stored, and\n" + "specify maximum number of backup files...\n") + + # Create a vertical layout for the page + layout = QVBoxLayout(page) + + + self.backup_widget = QWidget(page) + self.backup_layout = QVBoxLayout(self.backup_widget) + + # Create a widget for the log directory + log_dir_label = QLabel("Log Directory:") + self.log_dir_edit = QLineEdit() + self.log_dir_edit.setText(self.settings.value("log_dir", os.path.expanduser(self.log_dir))) + self.backup_layout.addWidget(log_dir_label) + self.backup_layout.addWidget(self.log_dir_edit) + + spacer = QSpacerItem(0, 40, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + self.backup_layout.addItem(spacer) + + backup_dir_label = QLabel("Backup Directory:") + self.backup_dir_edit = QLineEdit() + self.backup_dir_edit.setText(self.settings.value("backup_dir", os.path.expanduser(self.backup_dir))) + self.backup_layout.addWidget(backup_dir_label) + self.backup_layout.addWidget(self.backup_dir_edit) + + # Create a widget for the maximum number of backups + self.backup_spinbox = QSpinBox() + self.backup_spinbox.setRange(1, 100) + self.backup_spinbox.setValue(self.max_backups) + + self.backup_layout.addWidget(QLabel("Maximum Number of Backups:")) + self.backup_layout.addWidget(self.backup_spinbox) + + self.backup_int_spinbox = QSpinBox() + self.backup_int_spinbox.setRange(1, 30) + self.backup_int_spinbox.setValue(self.backup_interval) + + self.backup_layout.addWidget(QLabel("Backup Interval (mins):")) + self.backup_layout.addWidget(self.backup_int_spinbox) + + layout.addWidget(self.backup_widget) + + return page + + def create_save_page(self): + """ + Creates the page for the wizard to save the configuration file. Allows the user to + select/overwrite an existing configuration file or enter a new filename. + + :return: The page for the wizard to save the configuration file. + :rtype: QWizardPage + """ + page = QWizardPage() + page.setTitle("Save Configuration") + page.setSubTitle("\nPlease select an existing configuration file or enter a new filename...\n") + + # Create a vertical layout for the page + layout = QVBoxLayout(page) + + # Create QComboBox for the list of available .yml files + self.config_files_combobox = QComboBox() + for file in os.listdir(resource_dir): + if file.endswith('.yml'): + self.config_files_combobox.addItem(file) + + layout.addWidget(QLabel("Existing Configuration Files:")) + layout.addWidget(self.config_files_combobox) + + # Create QLineEdit for the filename + self.filename_edit = QLineEdit() + self.filename_edit.setPlaceholderText("config.yml") + self.connection_manager.connect(self.config_files_combobox.currentTextChanged, + self.update_config_combobox_state) + layout.addWidget(QLabel("New Filename (Optional):")) + layout.addWidget(self.filename_edit) + + # Display the save path + layout.addWidget(QLabel("Save directory:")) + save_dir_label = QLabel(resource_dir) + layout.addWidget(save_dir_label) + + return page + + def update_config_combobox_state(self): + """ + Updates the QComboBox on the save page with the list of existing .yml files. + """ + if self.filename_edit.text(): + self.config_files_combobox.setEnabled(False) + else: + self.config_files_combobox.setEnabled(True) + self.update_combobox_stylesheet() + + def update_combobox_stylesheet(self): + """ + Updates the stylesheet of the QComboBox on the save page to indicate whether it is + enabled or disabled. + """ + if self.config_files_combobox.isEnabled(): + self.config_files_combobox.setStyleSheet(f"""QComboBox {{ + color: {get_theme('dark_blue.xml')['primaryLightColor']}; + }}""") + else: + self.config_files_combobox.setStyleSheet("QComboBox { color: gray; }") + + def accept(self): + """ + Saves the configuration file and closes the wizard. + """ + # Get the filename from the QLineEdit or QComboBox + filename = self.filename_edit.text() + if not filename: + filename = self.config_files_combobox.currentText() + + # Add .yml extension if not provided by the user + if not filename.endswith('.yml'): + filename += '.yml' + + if bool(self.input_option_checkboxes["Checkbox"].isChecked()): + # Save the updated config data + # Save the updated config data + new_checkbox_labels = [] + for i in range(self.labels_layout.count()): + hbox = self.labels_layout.itemAt(i).layout() # Get the QHBoxLayout + if hbox is not None: + line_edit = hbox.itemAt(0).widget() # Get the QLineEdit from the QHBoxLayout + if line_edit.text(): + new_checkbox_labels.append(line_edit.text()) + self.config_data['checkboxes'] = new_checkbox_labels + else: + self.config_data['checkboxes'] = [] + + if bool(self.input_option_checkboxes["Radio Buttons"].isChecked()): + self.config_data['radiobuttons'] = self.radio_page.get_group_data() + else: + self.config_data['radiobuttons'] = [] + + self.config_data['tristate_checkboxes'] = self.tristate_checkboxes + self.config_data['max_backups'] = self.backup_spinbox.value() + self.config_data['backup_interval'] = self.backup_int_spinbox.value() + self.config_data['backup_dir'] = self.backup_dir_edit.text() + self.config_data['log_dir'] = self.log_dir_edit.text() + + save_path = os.path.join(resource_dir, filename) + + # Save the config file + with open(save_path, 'w') as f: + yaml.dump(self.config_data, f) + + # Makes a log of the new configuration + logger, console_msg = setup_logging(self.config_data['log_dir']) + logger.info(f"Configuration saved to {save_path}") + + # Inform the user that the configuration has been saved + QMessageBox.information(self, "Configuration Saved", "The configuration has been saved.") + + super().accept() + + +if __name__ == '__main__': + + # Create the application and apply the qt material stylesheet + app = QApplication([]) + apply_stylesheet(app, theme='dark_blue.xml') + + # Set the directory of the main.py file as the default directory for the config files + default_dir = resource_dir + + # Load the last config file used + settings = QSettings('SpeedyQC', 'DicomViewer') + config_file = settings.value('config_file', os.path.join(default_dir, 'config.yml')) + + # Create the configuration wizard + wizard = ConfigurationWizard(config_file) + + # Run the wizard + wizard.show() + + app.exec()