MIDRC-MELODY 0.3.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. MIDRC_MELODY/__init__.py +0 -0
  2. MIDRC_MELODY/__main__.py +4 -0
  3. MIDRC_MELODY/common/__init__.py +0 -0
  4. MIDRC_MELODY/common/data_loading.py +199 -0
  5. MIDRC_MELODY/common/data_preprocessing.py +134 -0
  6. MIDRC_MELODY/common/edit_config.py +156 -0
  7. MIDRC_MELODY/common/eod_aaod_metrics.py +292 -0
  8. MIDRC_MELODY/common/generate_eod_aaod_spiders.py +69 -0
  9. MIDRC_MELODY/common/generate_qwk_spiders.py +56 -0
  10. MIDRC_MELODY/common/matplotlib_spider.py +425 -0
  11. MIDRC_MELODY/common/plot_tools.py +132 -0
  12. MIDRC_MELODY/common/plotly_spider.py +217 -0
  13. MIDRC_MELODY/common/qwk_metrics.py +244 -0
  14. MIDRC_MELODY/common/table_tools.py +230 -0
  15. MIDRC_MELODY/gui/__init__.py +0 -0
  16. MIDRC_MELODY/gui/config_editor.py +200 -0
  17. MIDRC_MELODY/gui/data_loading.py +157 -0
  18. MIDRC_MELODY/gui/main_controller.py +154 -0
  19. MIDRC_MELODY/gui/main_window.py +545 -0
  20. MIDRC_MELODY/gui/matplotlib_spider_widget.py +204 -0
  21. MIDRC_MELODY/gui/metrics_model.py +62 -0
  22. MIDRC_MELODY/gui/plotly_spider_widget.py +56 -0
  23. MIDRC_MELODY/gui/qchart_spider_widget.py +272 -0
  24. MIDRC_MELODY/gui/shared/__init__.py +0 -0
  25. MIDRC_MELODY/gui/shared/react/__init__.py +0 -0
  26. MIDRC_MELODY/gui/shared/react/copyabletableview.py +100 -0
  27. MIDRC_MELODY/gui/shared/react/grabbablewidget.py +406 -0
  28. MIDRC_MELODY/gui/tqdm_handler.py +210 -0
  29. MIDRC_MELODY/melody.py +102 -0
  30. MIDRC_MELODY/melody_gui.py +111 -0
  31. MIDRC_MELODY/resources/MIDRC.ico +0 -0
  32. midrc_melody-0.3.3.dist-info/METADATA +151 -0
  33. midrc_melody-0.3.3.dist-info/RECORD +37 -0
  34. midrc_melody-0.3.3.dist-info/WHEEL +5 -0
  35. midrc_melody-0.3.3.dist-info/entry_points.txt +4 -0
  36. midrc_melody-0.3.3.dist-info/licenses/LICENSE +201 -0
  37. midrc_melody-0.3.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,100 @@
1
+ # Copyright (c) 2025 Medical Imaging and Data Resource Center (MIDRC).
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+
16
+ """
17
+ This module contains a custom QTableView subclass that allows copying selected data to the clipboard.
18
+ """
19
+
20
+ import csv
21
+ import io
22
+ from typing import List
23
+
24
+ from PySide6.QtCore import QDate, QEvent, QObject, Qt
25
+ from PySide6.QtGui import QGuiApplication, QKeySequence
26
+ from PySide6.QtWidgets import QMenu, QTableWidget
27
+
28
+
29
+ class CopyableTableWidget(QTableWidget):
30
+ """
31
+ Custom QTableView subclass that allows copying selected data to the clipboard.
32
+ """
33
+ def __init__(self) -> None:
34
+ """
35
+ Initialize the CopyableTableView and install its event filter.
36
+ """
37
+ super().__init__()
38
+ self.installEventFilter(self)
39
+
40
+ def eventFilter(self, source: QObject, event: QEvent) -> bool:
41
+ """
42
+ Filter events to handle key presses for copying selection.
43
+
44
+ Args:
45
+ source (QObject): The source object.
46
+ event (QEvent): The event to filter.
47
+
48
+ Returns:
49
+ bool: True if the event was handled, False otherwise.
50
+ """
51
+ if event.type() == QEvent.KeyPress:
52
+ if event == QKeySequence.Copy:
53
+ self.copy_selection()
54
+ return True
55
+ return super().eventFilter(source, event)
56
+
57
+ def copy_selection(self) -> None:
58
+ """
59
+ Copy the currently selected table data to the clipboard in a tab-delimited format.
60
+
61
+ Returns:
62
+ None
63
+ """
64
+ selection = self.selectedIndexes()
65
+ if selection:
66
+ rows: List[int] = sorted(index.row() for index in selection)
67
+ columns: List[int] = sorted(index.column() for index in selection)
68
+ rowcount: int = rows[-1] - rows[0] + 1
69
+ colcount: int = columns[-1] - columns[0] + 1
70
+ table: List[List[str]] = [[''] * colcount for _ in range(rowcount)]
71
+ for index in selection:
72
+ row = index.row() - rows[0]
73
+ column = index.column() - columns[0]
74
+ index_data = index.data()
75
+ if isinstance(index_data, QDate):
76
+ index_data = index_data.toString(format=Qt.ISODate)
77
+ table[row][column] = str(index_data)
78
+ stream = io.StringIO()
79
+ csv.writer(stream, delimiter='\t').writerows(table)
80
+ QGuiApplication.clipboard().setText(stream.getvalue())
81
+
82
+ def contextMenuEvent(self, event) -> None:
83
+ """
84
+ Create a context menu with 'Select All' and 'Copy' options on right-click.
85
+
86
+ Args:
87
+ event (QContextMenuEvent): The context menu event.
88
+
89
+ Returns:
90
+ None
91
+ """
92
+ menu = QMenu(self)
93
+ select_all_action = menu.addAction("Select All")
94
+ copy_action = menu.addAction("Copy")
95
+
96
+ action = menu.exec(event.globalPos())
97
+ if action == select_all_action:
98
+ self.selectAll()
99
+ elif action == copy_action:
100
+ self.copy_selection()
@@ -0,0 +1,406 @@
1
+ # Copyright (c) 2025 Medical Imaging and Data Resource Center (MIDRC).
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+
16
+ """
17
+ This module provides a mixin class for adding snapshot (grab) and save functionalities to a QWidget.
18
+ """
19
+
20
+ from typing import Optional
21
+
22
+ from PySide6.QtCharts import QChart, QChartView
23
+ from PySide6.QtCore import QDateTime, QDir, QEvent, QObject, QStandardPaths, Qt
24
+ from PySide6.QtGui import QAction, QImage, QPainter
25
+ from PySide6.QtWidgets import (QApplication, QDialog, QFileDialog, QHBoxLayout, QLabel, QLayout, QMenu, QPushButton,
26
+ QVBoxLayout, QWidget)
27
+
28
+
29
+ class GrabbableWidgetMixin(QObject):
30
+ """
31
+ Mixin class for adding snapshot (grab) and save functionalities to a QWidget.
32
+
33
+ Attributes:
34
+ DEFAULT_SAVE_FILE_PREFIX (str): Default prefix for save file names.
35
+ DATE_TIME_FORMAT (str): The datetime format for appending to default filenames when saving snapshots
36
+ save_dialog_open (bool): Indicates if the save dialog is currently open.
37
+ """
38
+ DEFAULT_SAVE_FILE_PREFIX = "MIDRC-REACT_plot_"
39
+ DATE_TIME_FORMAT = "yyyyMMddhhmmss" # Constant for date-time format
40
+
41
+ def __init__(self, parent: QWidget = None, save_file_prefix: str = DEFAULT_SAVE_FILE_PREFIX) -> None:
42
+ """
43
+ Initialize the class instance with optional parent QWidget and a save file prefix.
44
+ Set the save dialog status to False by default.
45
+
46
+ Args:
47
+ parent (QWidget): The parent widget for the mixin.
48
+ save_file_prefix (str): The prefix for save file names.
49
+ """
50
+ self.save_dialog_open = False # Indicates if the save dialog is currently open
51
+ super().__init__(parent)
52
+ self.copyable_data: str | None = None # new attribute for copyable text data
53
+ self.save_file_prefix = save_file_prefix
54
+ parent.setContextMenuPolicy(Qt.CustomContextMenu)
55
+ parent.customContextMenuRequested.connect(self.show_context_menu)
56
+ parent.installEventFilter(self)
57
+ self.parent = parent
58
+
59
+ def eventFilter(self, source, event):
60
+ """
61
+ Filters events for the widget to handle context menu events.
62
+
63
+ Args:
64
+ source: The source of the event.
65
+ event: The event to be filtered.
66
+
67
+ Returns:
68
+ bool: True if the event is a context menu event for the parent widget, False otherwise.
69
+ """
70
+ if event.type() == QEvent.ContextMenu and source == self.parent:
71
+ self.show_context_menu(event.pos())
72
+ return True
73
+ return super().eventFilter(source, event)
74
+
75
+ def show_context_menu(self, pos):
76
+ """
77
+ Display a context menu for the widget for capturing snapshots of the widget.
78
+
79
+ Args:
80
+ pos: The position where the context menu should be displayed.
81
+
82
+ Returns:
83
+ None
84
+ """
85
+ context_menu = QMenu(self.parent)
86
+ # Copy Data, enabled only if copyable_data is set
87
+ copy_data_action = QAction("Copy Data", self.parent)
88
+ copy_data_action.setEnabled(bool(self.copyable_data))
89
+ copy_data_action.triggered.connect(self.copy_data_to_clipboard)
90
+ context_menu.addAction(copy_data_action)
91
+ copy_action = QAction("Copy Image", self.parent)
92
+ save_action = QAction("Save Image", self.parent)
93
+
94
+ copy_action.triggered.connect(self.copy_to_clipboard)
95
+ save_action.triggered.connect(self.save_to_disk)
96
+
97
+ context_menu.addAction(copy_action)
98
+ context_menu.addAction(save_action)
99
+
100
+ # Only display this action if we don't have the high-res save dialog open for this widget
101
+ if not self.save_dialog_open:
102
+ save_high_res_action = QAction("Save High Resolution Image", self.parent)
103
+ save_high_res_action.triggered.connect(self.save_high_res_to_disk)
104
+ context_menu.addAction(save_high_res_action)
105
+
106
+ context_menu.exec(self.parent.mapToGlobal(pos))
107
+
108
+ def copy_to_clipboard(self):
109
+ """
110
+ Copy the snapshot of the parent widget to the clipboard.
111
+
112
+ Returns:
113
+ None
114
+ """
115
+ clipboard = QApplication.clipboard()
116
+ if clipboard is not None:
117
+ snapshot = self.parent.grab()
118
+ clipboard.setPixmap(snapshot)
119
+ del snapshot
120
+
121
+ @staticmethod
122
+ def create_directory():
123
+ """
124
+ Create the directory for saving snapshots if it does not exist.
125
+
126
+ Returns:
127
+ QDir: The directory path for saving the snapshot.
128
+ """
129
+ subdir = "Screenshots"
130
+ pictures_location = QStandardPaths.writableLocation(QStandardPaths.PicturesLocation)
131
+ screenshots_dir = QDir(pictures_location)
132
+ screenshots_dir.mkpath(subdir)
133
+ screenshots_dir.cd(subdir)
134
+
135
+ return screenshots_dir
136
+
137
+ def default_filename(self, suffix: str = ".png") -> str:
138
+ """
139
+ Generate a default filename for saving snapshots.
140
+
141
+ Args:
142
+ suffix: The file extension to be appended to the filename. Default is '.png'.
143
+
144
+ Returns:
145
+ str: The default file path for saving the snapshot.
146
+ """
147
+ # Get the default save directory
148
+ screenshots_dir = self.create_directory()
149
+
150
+ # Set default file name using QDateTime and the constant format
151
+ default_filename = self.save_file_prefix + QDateTime.currentDateTime().toString(self.DATE_TIME_FORMAT) + suffix
152
+ return screenshots_dir.filePath(default_filename)
153
+
154
+ def save_to_disk(self):
155
+ """
156
+ Save a snapshot of the parent widget to disk.
157
+
158
+ Prompts the user to choose a file location and format for saving the snapshot.
159
+ If a file is selected, the snapshot of the widget is saved to the chosen location.
160
+
161
+ Returns:
162
+ None
163
+ """
164
+ options = QFileDialog.Options()
165
+ file_name, _ = QFileDialog.getSaveFileName(self.parent, "Save Snapshot", self.default_filename(".png"),
166
+ "PNG Files (*.png);;JPEG Files (*.jpg);;All Files (*)",
167
+ options=options)
168
+ if file_name:
169
+ snapshot = self.parent.grab()
170
+ snapshot.save(file_name)
171
+ del snapshot
172
+
173
+ def save_high_res_to_disk(self) -> None:
174
+ """
175
+ Save a high-resolution snapshot of the parent widget to disk.
176
+
177
+ Opens a dialog to capture a high-resolution snapshot of the widget and saves it to the chosen file location.
178
+ Resets the save dialog status after saving the snapshot.
179
+
180
+ Returns:
181
+ None
182
+ """
183
+ self.save_dialog_open = True
184
+ screenshot_dialog = SaveWidgetAsImageDialog(self.parent)
185
+ screenshot_dialog.exec()
186
+ high_res_snapshot = screenshot_dialog.image
187
+
188
+ if (not high_res_snapshot.isNull()) and screenshot_dialog.result() == QDialog.Accepted:
189
+ options = QFileDialog.Options()
190
+ file_name, _ = QFileDialog.getSaveFileName(self.parent, "Save High Resolution Snapshot",
191
+ self.default_filename("_highres.png"),
192
+ "PNG Files (*.png);;JPEG Files (*.jpg);;All Files (*)",
193
+ options=options)
194
+ if file_name:
195
+ high_res_snapshot.save(file_name)
196
+
197
+ self.save_dialog_open = False
198
+
199
+ def copy_data_to_clipboard(self):
200
+ """
201
+ Copy the stored copyable_data text to the clipboard.
202
+ """
203
+ if self.copyable_data:
204
+ clipboard = QApplication.clipboard()
205
+ clipboard.setText(self.copyable_data)
206
+
207
+
208
+ class SaveWidgetAsImageDialog(QDialog):
209
+ """
210
+ Dialog for saving a widget as an image with options to restore, cancel, and save the image with a specified ratio.
211
+
212
+ Attributes:
213
+ widget: The widget to be saved as an image.
214
+ """
215
+ WINDOW_TITLE = "Save High Resolution Image"
216
+ WINDOW_WIDTH = 400
217
+ WINDOW_HEIGHT = 300
218
+
219
+ def __init__(self, widget: QWidget, parent: Optional[QWidget] = None):
220
+ """
221
+ Initialize the SaveWidgetAsImageDialog with the specified widget and optional parent.
222
+
223
+ Parameters:
224
+ widget (QWidget): The widget to be saved as an image.
225
+ parent (Optional[QWidget]): The optional parent widget for the dialog.
226
+ """
227
+ super().__init__(parent)
228
+
229
+ self.setWindowTitle(self.WINDOW_TITLE)
230
+ self.resize(self.WINDOW_WIDTH, self.WINDOW_HEIGHT)
231
+
232
+ self._image: QImage = QImage()
233
+ self.widget: QWidget = widget
234
+ self.temp_widget: Optional[QLabel] = None
235
+ self.widget_parent_layout: Optional[QLayout] = None
236
+
237
+ self._create_temp_widget()
238
+ self._setup_layout()
239
+
240
+ @property
241
+ def image(self) -> QImage:
242
+ """
243
+ Get the image generated from the widget.
244
+
245
+ Returns:
246
+ QImage: The image generated from the widget.
247
+ """
248
+ return self._image
249
+
250
+ def _create_temp_widget(self):
251
+ """
252
+ Create a temporary widget to display the image before saving.
253
+
254
+ Returns:
255
+ None
256
+ """
257
+ self.temp_widget = QLabel()
258
+ self.temp_widget.setPixmap(self.widget.grab())
259
+ self.widget_parent_layout = self.widget.parentWidget().layout()
260
+ if self.widget_parent_layout is None: # This means the parent handles the layout instead of having a layout
261
+ self.widget_parent_index = self.widget.parentWidget().indexOf(self.widget)
262
+ self.widget.parentWidget().replaceWidget(self.widget_parent_index, self.temp_widget)
263
+ else:
264
+ self.widget_parent_layout.replaceWidget(self.widget, self.temp_widget)
265
+
266
+ def _setup_layout(self) -> None:
267
+ """
268
+ Set up the layout for the dialog including the main widget and buttons.
269
+ """
270
+ layout = QVBoxLayout(self)
271
+ layout.addWidget(self.widget)
272
+
273
+ self._setup_buttons(layout)
274
+
275
+ def _setup_buttons(self, layout):
276
+ """
277
+ Create and configure the Save and Cancel buttons.
278
+
279
+ Parameters:
280
+ layout: The layout to add the buttons to.
281
+ """
282
+ button_layout = QHBoxLayout()
283
+
284
+ save_button = QPushButton("Save", self)
285
+ cancel_button = QPushButton("Cancel", self)
286
+
287
+ save_button.clicked.connect(self.save_image)
288
+ cancel_button.clicked.connect(self.cancel_save)
289
+
290
+ button_layout.addWidget(save_button)
291
+ button_layout.addWidget(cancel_button)
292
+
293
+ layout.addLayout(button_layout)
294
+
295
+ def _restore_widget(self):
296
+ """
297
+ Restore the original widget by removing the temporary widget and replacing it with the original widget.
298
+
299
+ This method reverts the changes made for displaying the temporary widget.
300
+ """
301
+ self.layout().removeWidget(self.widget)
302
+ if self.widget_parent_layout is None: # The parent handles the layout instead of having a layout
303
+ self.temp_widget.parentWidget().replaceWidget(self.widget_parent_index, self.widget)
304
+ else:
305
+ self.widget_parent_layout.replaceWidget(self.temp_widget, self.widget)
306
+ self.temp_widget.deleteLater()
307
+
308
+ def cancel_save(self):
309
+ """
310
+ Restore the original widget and reject the save operation.
311
+
312
+ This method restores the original widget by removing the temporary widget and replaces it with the original.
313
+ Then, it rejects the save operation.
314
+ """
315
+ self._restore_widget()
316
+ self.reject()
317
+
318
+ def closeEvent(self, event):
319
+ """
320
+ Handle the event when the dialog is closed.
321
+
322
+ Parameters:
323
+ event (QCloseEvent): The close event triggered when the dialog is closed.
324
+ """
325
+ # Call the cancel_save method when the dialog is closed
326
+ self.cancel_save()
327
+
328
+ # Accept the event to allow the dialog to close
329
+ event.accept()
330
+
331
+ def save_image(self, _=None, *, ratio: int = 2):
332
+ """
333
+ Save the image of the widget with a specified ratio.
334
+
335
+ Creates an image of the widget with a given ratio, renders it using a QPainter, and then restores the original.
336
+ Finally, accepts the save operation.
337
+
338
+ Parameters:
339
+ _ : Signals can send a parameter, so ignore it
340
+ ratio (int): The ratio to scale the image.
341
+ """
342
+ self._image = QImage(round(ratio * self.widget.width()),
343
+ round(ratio * self.widget.height()),
344
+ QImage.Format_RGB32)
345
+ # self.image.setDevicePixelRatio(ratio) # The QPainter handles this automatically
346
+ painter = QPainter(self._image)
347
+ self.widget.render(painter)
348
+ painter.end()
349
+
350
+ self._restore_widget()
351
+ self.accept()
352
+
353
+
354
+ class GrabbableChartView(QChartView):
355
+ """
356
+ Subclass of QChartView for creating a chart view that can be grabbed and saved as an image.
357
+
358
+ Inherits functionality from QChartView and adds the ability to save the chart as an image.
359
+ """
360
+ def __init__(self, chart: QChart, parent: Optional[QWidget] = None,
361
+ save_file_prefix: str = GrabbableWidgetMixin.DEFAULT_SAVE_FILE_PREFIX) -> None:
362
+ """
363
+ Initialize the class instance with an optional parent QWidget, a save file prefix, and a GrabbableWidgetMixin.
364
+
365
+ Args:
366
+ chart (QChart): The chart to be displayed in the view.
367
+ parent (Optional[QWidget]): The optional parent QWidget for the chart view.
368
+ save_file_prefix (str): The prefix for save file names.
369
+
370
+ Connect context menu signals for the parent widget to show the context menu and handle events.
371
+ """
372
+ super().__init__(chart, parent)
373
+ self.grabbable_mixin = GrabbableWidgetMixin(self, save_file_prefix)
374
+
375
+ def save_chart_to_disk(self):
376
+ """
377
+ Save the chart view to disk using the `save_to_disk` functionality from `GrabbableWidgetMixin`.
378
+
379
+ Returns:
380
+ None
381
+ """
382
+ self.grabbable_mixin.save_to_disk()
383
+
384
+ @property
385
+ def copyable_data(self) -> str:
386
+ """
387
+ Get the copyable data for the chart view.
388
+
389
+ Returns:
390
+ str: The data to be copied to the clipboard when requested.
391
+ """
392
+ return self.grabbable_mixin.copyable_data
393
+
394
+ @copyable_data.setter
395
+ def copyable_data(self, data: str):
396
+ """
397
+ Set the copyable data for the chart view.
398
+
399
+ Args:
400
+ data (str): The data to be copied to the clipboard when requested.
401
+
402
+ Returns:
403
+ None
404
+ """
405
+ self.grabbable_mixin.copyable_data = data
406
+