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.
- MIDRC_MELODY/__init__.py +0 -0
- MIDRC_MELODY/__main__.py +4 -0
- MIDRC_MELODY/common/__init__.py +0 -0
- MIDRC_MELODY/common/data_loading.py +199 -0
- MIDRC_MELODY/common/data_preprocessing.py +134 -0
- MIDRC_MELODY/common/edit_config.py +156 -0
- MIDRC_MELODY/common/eod_aaod_metrics.py +292 -0
- MIDRC_MELODY/common/generate_eod_aaod_spiders.py +69 -0
- MIDRC_MELODY/common/generate_qwk_spiders.py +56 -0
- MIDRC_MELODY/common/matplotlib_spider.py +425 -0
- MIDRC_MELODY/common/plot_tools.py +132 -0
- MIDRC_MELODY/common/plotly_spider.py +217 -0
- MIDRC_MELODY/common/qwk_metrics.py +244 -0
- MIDRC_MELODY/common/table_tools.py +230 -0
- MIDRC_MELODY/gui/__init__.py +0 -0
- MIDRC_MELODY/gui/config_editor.py +200 -0
- MIDRC_MELODY/gui/data_loading.py +157 -0
- MIDRC_MELODY/gui/main_controller.py +154 -0
- MIDRC_MELODY/gui/main_window.py +545 -0
- MIDRC_MELODY/gui/matplotlib_spider_widget.py +204 -0
- MIDRC_MELODY/gui/metrics_model.py +62 -0
- MIDRC_MELODY/gui/plotly_spider_widget.py +56 -0
- MIDRC_MELODY/gui/qchart_spider_widget.py +272 -0
- MIDRC_MELODY/gui/shared/__init__.py +0 -0
- MIDRC_MELODY/gui/shared/react/__init__.py +0 -0
- MIDRC_MELODY/gui/shared/react/copyabletableview.py +100 -0
- MIDRC_MELODY/gui/shared/react/grabbablewidget.py +406 -0
- MIDRC_MELODY/gui/tqdm_handler.py +210 -0
- MIDRC_MELODY/melody.py +102 -0
- MIDRC_MELODY/melody_gui.py +111 -0
- MIDRC_MELODY/resources/MIDRC.ico +0 -0
- midrc_melody-0.3.3.dist-info/METADATA +151 -0
- midrc_melody-0.3.3.dist-info/RECORD +37 -0
- midrc_melody-0.3.3.dist-info/WHEEL +5 -0
- midrc_melody-0.3.3.dist-info/entry_points.txt +4 -0
- midrc_melody-0.3.3.dist-info/licenses/LICENSE +201 -0
- midrc_melody-0.3.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,200 @@
|
|
|
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
|
+
from PySide6.QtWidgets import (QCheckBox, QDialog, QDoubleSpinBox, QFormLayout, QHBoxLayout, QLabel, QLineEdit,
|
|
17
|
+
QMessageBox, QPushButton, QSpinBox, QTabWidget, QVBoxLayout, QWidget)
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConfigEditor(QDialog):
|
|
22
|
+
def __init__(self, config: dict, parent=None):
|
|
23
|
+
super().__init__(parent)
|
|
24
|
+
self.config = config # store reference to config dict
|
|
25
|
+
self.setWindowTitle("Edit Config")
|
|
26
|
+
self.resize(600, 400)
|
|
27
|
+
main_layout = QVBoxLayout(self)
|
|
28
|
+
self.tab_widget = QTabWidget()
|
|
29
|
+
main_layout.addWidget(self.tab_widget)
|
|
30
|
+
|
|
31
|
+
# Initialize class variables
|
|
32
|
+
self.input_edits = {}
|
|
33
|
+
self.calc_edits = {}
|
|
34
|
+
self.output_widgets = {}
|
|
35
|
+
self.numeric_edit = None
|
|
36
|
+
self.custom_orders_edit = None
|
|
37
|
+
self.clockwise_checkbox = None
|
|
38
|
+
self.start_edit = None
|
|
39
|
+
self.bootstrap_iterations = None
|
|
40
|
+
self.bootstrap_seed = None
|
|
41
|
+
|
|
42
|
+
self.setup_input_tab()
|
|
43
|
+
self.setup_calculations_tab()
|
|
44
|
+
self.setup_output_tab()
|
|
45
|
+
self.setup_plots_tab()
|
|
46
|
+
|
|
47
|
+
# Button layout: Cancel, Apply, Save
|
|
48
|
+
btn_layout = QHBoxLayout()
|
|
49
|
+
cancel_btn = QPushButton("Cancel")
|
|
50
|
+
cancel_btn.clicked.connect(self.reject)
|
|
51
|
+
apply_btn = QPushButton("Apply")
|
|
52
|
+
apply_btn.clicked.connect(self.apply_changes)
|
|
53
|
+
save_btn = QPushButton("Save")
|
|
54
|
+
save_btn.clicked.connect(self.on_save)
|
|
55
|
+
btn_layout.addWidget(cancel_btn)
|
|
56
|
+
btn_layout.addWidget(apply_btn)
|
|
57
|
+
btn_layout.addWidget(save_btn)
|
|
58
|
+
main_layout.addLayout(btn_layout)
|
|
59
|
+
|
|
60
|
+
def setup_input_tab(self):
|
|
61
|
+
self.input_edits = {}
|
|
62
|
+
input_tab = QWidget()
|
|
63
|
+
input_layout = QFormLayout(input_tab)
|
|
64
|
+
for key, value in self.config.get("input data", {}).items():
|
|
65
|
+
hbox = QHBoxLayout()
|
|
66
|
+
le = QLineEdit(str(value))
|
|
67
|
+
self.input_edits[key] = le
|
|
68
|
+
hbox.addWidget(le)
|
|
69
|
+
if key in ['truth file', 'test scores']:
|
|
70
|
+
browse_btn = QPushButton("Browse")
|
|
71
|
+
browse_btn.clicked.connect(lambda _, le=le: self.browse_file(le))
|
|
72
|
+
hbox.addWidget(browse_btn)
|
|
73
|
+
input_layout.addRow(QLabel(key), hbox)
|
|
74
|
+
numeric = self.config.get("numeric_cols", {})
|
|
75
|
+
numeric_str = "\n".join(f"{k}: {v}" for k, v in numeric.items())
|
|
76
|
+
self.numeric_edit = QLineEdit(numeric_str)
|
|
77
|
+
input_layout.addRow(QLabel("numeric_cols"), self.numeric_edit)
|
|
78
|
+
self.tab_widget.addTab(input_tab, "Input")
|
|
79
|
+
|
|
80
|
+
def browse_file(self, line_edit: QLineEdit):
|
|
81
|
+
from PySide6.QtWidgets import QFileDialog
|
|
82
|
+
file_path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "All Files (*)")
|
|
83
|
+
if file_path:
|
|
84
|
+
line_edit.setText(file_path)
|
|
85
|
+
|
|
86
|
+
def setup_calculations_tab(self):
|
|
87
|
+
self.calc_edits = {}
|
|
88
|
+
calc_tab = QWidget()
|
|
89
|
+
calc_layout = QFormLayout(calc_tab)
|
|
90
|
+
# Use QDoubleSpinBox for binary threshold
|
|
91
|
+
if "binary threshold" in self.config:
|
|
92
|
+
threshold_value = self.config["binary threshold"]
|
|
93
|
+
threshold_spin = QDoubleSpinBox()
|
|
94
|
+
threshold_spin.setDecimals(2)
|
|
95
|
+
threshold_spin.setRange(0, 1000) # adjust range as needed
|
|
96
|
+
threshold_spin.setValue(float(threshold_value))
|
|
97
|
+
self.calc_edits["binary threshold"] = threshold_spin
|
|
98
|
+
calc_layout.addRow(QLabel("binary threshold"), threshold_spin)
|
|
99
|
+
# Use QSpinBox for min count per category
|
|
100
|
+
if "min count per category" in self.config:
|
|
101
|
+
min_count = self.config["min count per category"]
|
|
102
|
+
min_count_spin = QSpinBox()
|
|
103
|
+
min_count_spin.setMinimum(0)
|
|
104
|
+
min_count_spin.setMaximum(10000) # adjust range as needed
|
|
105
|
+
min_count_spin.setValue(int(min_count))
|
|
106
|
+
self.calc_edits["min count per category"] = min_count_spin
|
|
107
|
+
calc_layout.addRow(QLabel("min count per category"), min_count_spin)
|
|
108
|
+
# Bootstrap settings using QSpinBox
|
|
109
|
+
bootstrap = self.config.get("bootstrap", {})
|
|
110
|
+
self.bootstrap_iterations = QSpinBox()
|
|
111
|
+
self.bootstrap_iterations.setMinimum(0)
|
|
112
|
+
self.bootstrap_iterations.setMaximum(1000000)
|
|
113
|
+
self.bootstrap_iterations.setValue(int(bootstrap.get("iterations", 0)))
|
|
114
|
+
calc_layout.addRow(QLabel("Bootstrap Iterations"), self.bootstrap_iterations)
|
|
115
|
+
self.bootstrap_seed = QSpinBox()
|
|
116
|
+
self.bootstrap_seed.setMinimum(0)
|
|
117
|
+
self.bootstrap_seed.setMaximum(1000000)
|
|
118
|
+
self.bootstrap_seed.setValue(int(bootstrap.get("seed", 0)))
|
|
119
|
+
calc_layout.addRow(QLabel("Bootstrap Seed"), self.bootstrap_seed)
|
|
120
|
+
self.tab_widget.addTab(calc_tab, "Calculations")
|
|
121
|
+
|
|
122
|
+
def setup_output_tab(self):
|
|
123
|
+
self.output_widgets = {}
|
|
124
|
+
output_tab = QWidget()
|
|
125
|
+
output_layout = QFormLayout(output_tab)
|
|
126
|
+
output = self.config.get("output", {})
|
|
127
|
+
for subcat, settings in output.items():
|
|
128
|
+
row_widget = QWidget()
|
|
129
|
+
row_layout = QHBoxLayout(row_widget)
|
|
130
|
+
row_layout.setContentsMargins(0, 0, 0, 0)
|
|
131
|
+
cb = QCheckBox("Save")
|
|
132
|
+
cb.setChecked(settings.get("save", False))
|
|
133
|
+
le = QLineEdit(str(settings.get("file prefix", "")))
|
|
134
|
+
le.setEnabled(cb.isChecked())
|
|
135
|
+
cb.toggled.connect(lambda checked, le=le: le.setEnabled(checked))
|
|
136
|
+
row_layout.addWidget(cb)
|
|
137
|
+
row_layout.addWidget(QLabel("File Prefix:"))
|
|
138
|
+
row_layout.addWidget(le)
|
|
139
|
+
output_layout.addRow(QLabel(subcat.capitalize()), row_widget)
|
|
140
|
+
self.output_widgets[subcat] = (cb, le)
|
|
141
|
+
self.tab_widget.addTab(output_tab, "Pickle (.pkl) Output")
|
|
142
|
+
|
|
143
|
+
def setup_plots_tab(self):
|
|
144
|
+
plots_tab = QWidget()
|
|
145
|
+
plots_layout = QFormLayout(plots_tab)
|
|
146
|
+
plot = self.config.get("plot", {})
|
|
147
|
+
custom_orders = plot.get("custom_orders", {})
|
|
148
|
+
custom_orders_str = "\n".join(f"{k}: {v}" for k, v in custom_orders.items())
|
|
149
|
+
self.custom_orders_edit = QLineEdit(custom_orders_str)
|
|
150
|
+
plots_layout.addRow(QLabel("Custom Orders"), self.custom_orders_edit)
|
|
151
|
+
self.clockwise_checkbox = QCheckBox()
|
|
152
|
+
self.clockwise_checkbox.setChecked(plot.get("clockwise", False))
|
|
153
|
+
plots_layout.addRow(QLabel("Clockwise"), self.clockwise_checkbox)
|
|
154
|
+
self.start_edit = QLineEdit(str(plot.get("start", "")))
|
|
155
|
+
plots_layout.addRow(QLabel("Start"), self.start_edit)
|
|
156
|
+
self.tab_widget.addTab(plots_tab, "Plots")
|
|
157
|
+
|
|
158
|
+
def apply_changes(self):
|
|
159
|
+
# Input Tab
|
|
160
|
+
for key, widget in self.input_edits.items():
|
|
161
|
+
self.config.setdefault("input data", {})[key] = widget.text()
|
|
162
|
+
try:
|
|
163
|
+
new_numeric = yaml.load(self.numeric_edit.text(), Loader=yaml.SafeLoader)
|
|
164
|
+
if not isinstance(new_numeric, dict):
|
|
165
|
+
raise ValueError("numeric_cols must be a dictionary")
|
|
166
|
+
except Exception as e:
|
|
167
|
+
QMessageBox.warning(self, "Warning", f"Invalid format for numeric_cols: {e}")
|
|
168
|
+
new_numeric = self.config.get("numeric_cols", {})
|
|
169
|
+
self.config["numeric_cols"] = new_numeric
|
|
170
|
+
|
|
171
|
+
# Calculations Tab: use spinbox values
|
|
172
|
+
for key, widget in self.calc_edits.items():
|
|
173
|
+
self.config[key] = widget.value() # Use value() from spinboxes
|
|
174
|
+
self.config["bootstrap"] = {
|
|
175
|
+
"iterations": self.bootstrap_iterations.value(),
|
|
176
|
+
"seed": self.bootstrap_seed.value()
|
|
177
|
+
}
|
|
178
|
+
# Output Tab
|
|
179
|
+
for subcat, (cb, le) in self.output_widgets.items():
|
|
180
|
+
self.config.setdefault("output", {})[subcat] = {
|
|
181
|
+
"save": cb.isChecked(),
|
|
182
|
+
"file prefix": le.text()
|
|
183
|
+
}
|
|
184
|
+
# Plots Tab: Parse custom_orders into a dictionary
|
|
185
|
+
try:
|
|
186
|
+
new_custom_orders = yaml.load(self.custom_orders_edit.text(), Loader=yaml.SafeLoader)
|
|
187
|
+
if not isinstance(new_custom_orders, dict):
|
|
188
|
+
raise ValueError("custom_orders must be a dictionary")
|
|
189
|
+
except Exception as e:
|
|
190
|
+
QMessageBox.warning(self, "Warning", f"Invalid format for custom_orders: {e}")
|
|
191
|
+
new_custom_orders = self.config.get("plot", {}).get("custom_orders", {})
|
|
192
|
+
self.config.setdefault("plot", {})["custom_orders"] = new_custom_orders
|
|
193
|
+
|
|
194
|
+
self.config["plot"]["clockwise"] = self.clockwise_checkbox.isChecked()
|
|
195
|
+
self.config["plot"]["start"] = self.start_edit.text()
|
|
196
|
+
QMessageBox.information(self, "Applied", "Configuration updated.")
|
|
197
|
+
|
|
198
|
+
def on_save(self):
|
|
199
|
+
self.apply_changes()
|
|
200
|
+
self.accept()
|
|
@@ -0,0 +1,157 @@
|
|
|
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
|
+
import json
|
|
17
|
+
import yaml
|
|
18
|
+
import os
|
|
19
|
+
|
|
20
|
+
from PySide6.QtCore import QSettings
|
|
21
|
+
from PySide6.QtWidgets import (
|
|
22
|
+
QFileDialog,
|
|
23
|
+
QMessageBox,
|
|
24
|
+
QDialog,
|
|
25
|
+
)
|
|
26
|
+
from MIDRC_MELODY.gui.config_editor import ConfigEditor
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_config_dict() -> dict:
|
|
30
|
+
"""
|
|
31
|
+
Load config settings from QSettings or fallback to config.yaml in the repo root.
|
|
32
|
+
Post-process numeric columns so that any ".inf"/"inf" strings become float("inf").
|
|
33
|
+
"""
|
|
34
|
+
settings = QSettings("MIDRC", "MIDRC-MELODY")
|
|
35
|
+
config_str = settings.value("config", "")
|
|
36
|
+
if config_str:
|
|
37
|
+
config = json.loads(config_str)
|
|
38
|
+
else:
|
|
39
|
+
# Default path: three levels up from this file
|
|
40
|
+
config_path = os.path.join(os.path.dirname(__file__), ".", ".", ".", "config.yaml")
|
|
41
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
42
|
+
config = yaml.load(f, Loader=yaml.CLoader)
|
|
43
|
+
settings.setValue("config", json.dumps(config))
|
|
44
|
+
|
|
45
|
+
# Convert any ".inf"/"inf" bins into actual float("inf")
|
|
46
|
+
if "numeric_cols" in config:
|
|
47
|
+
for col, d in config["numeric_cols"].items():
|
|
48
|
+
if "bins" in d:
|
|
49
|
+
processed_bins = []
|
|
50
|
+
for b in d["bins"]:
|
|
51
|
+
if isinstance(b, (int, float)):
|
|
52
|
+
processed_bins.append(b)
|
|
53
|
+
else:
|
|
54
|
+
try:
|
|
55
|
+
if b.strip() in [".inf", "inf"]:
|
|
56
|
+
processed_bins.append(float("inf"))
|
|
57
|
+
else:
|
|
58
|
+
processed_bins.append(float(b))
|
|
59
|
+
except Exception:
|
|
60
|
+
processed_bins.append(b)
|
|
61
|
+
config["numeric_cols"][col]["bins"] = processed_bins
|
|
62
|
+
|
|
63
|
+
return config
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def save_config_dict(config: dict) -> None:
|
|
68
|
+
settings = QSettings("MIDRC", "MIDRC-MELODY")
|
|
69
|
+
settings.setValue("config", json.dumps(config))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_config_file(parent):
|
|
73
|
+
"""
|
|
74
|
+
Attempt to load configuration from QSettings. If none exists, prompt the
|
|
75
|
+
user to pick a YAML/JSON file. Any errors are shown via QMessageBox.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
# This will raise if no config is stored in QSettings
|
|
79
|
+
_ = load_config_dict()
|
|
80
|
+
QMessageBox.information(parent, "Config Loaded", "Configuration loaded from QSettings.")
|
|
81
|
+
except Exception:
|
|
82
|
+
# Prompt user to select a file on disk
|
|
83
|
+
file_path, _ = QFileDialog.getOpenFileName(
|
|
84
|
+
parent,
|
|
85
|
+
"Select Config File",
|
|
86
|
+
os.path.expanduser("~"),
|
|
87
|
+
"Config Files (*.yaml *.json);;All Files (*)"
|
|
88
|
+
)
|
|
89
|
+
if file_path:
|
|
90
|
+
try:
|
|
91
|
+
# Read JSON or YAML and then save into QSettings via save_config_dict
|
|
92
|
+
if file_path.endswith(".json"):
|
|
93
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
94
|
+
config = json.load(f)
|
|
95
|
+
else:
|
|
96
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
97
|
+
config = yaml.load(f, Loader=yaml.CLoader)
|
|
98
|
+
|
|
99
|
+
save_config_dict(config) # persist into QSettings
|
|
100
|
+
QMessageBox.information(parent, "Config Loaded", "Configuration loaded from file.")
|
|
101
|
+
except Exception as e2:
|
|
102
|
+
QMessageBox.critical(parent, "Error", f"Failed to load selected config file:\n{e2}")
|
|
103
|
+
else:
|
|
104
|
+
QMessageBox.critical(parent, "Error", "No config file selected.")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def edit_config_file(parent):
|
|
108
|
+
"""
|
|
109
|
+
Open the ConfigEditor on the current config (pulled via load_config_dict).
|
|
110
|
+
If loading fails, ask whether to pick an existing config file or start blank.
|
|
111
|
+
Upon acceptance, persist via save_config_dict.
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
config = load_config_dict()
|
|
115
|
+
editor = ConfigEditor(config, parent=parent)
|
|
116
|
+
if editor.exec() == QDialog.Accepted:
|
|
117
|
+
save_config_dict(config)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
# If load_config_dict() failed, ask user if they want to pick an existing file
|
|
120
|
+
resp = QMessageBox.question(
|
|
121
|
+
parent,
|
|
122
|
+
"Edit Config",
|
|
123
|
+
f"Failed to load config: {e}\n\nWould you like to select an existing config file?\n"
|
|
124
|
+
"Press Yes to select a file; or No to create a blank config.",
|
|
125
|
+
QMessageBox.Yes | QMessageBox.No,
|
|
126
|
+
)
|
|
127
|
+
if resp == QMessageBox.Yes:
|
|
128
|
+
# User chose to pick a file
|
|
129
|
+
file_path, _ = QFileDialog.getOpenFileName(
|
|
130
|
+
parent,
|
|
131
|
+
"Select Config File",
|
|
132
|
+
os.path.expanduser("~"),
|
|
133
|
+
"Config Files (*.yaml *.json);;All Files (*)"
|
|
134
|
+
)
|
|
135
|
+
if file_path:
|
|
136
|
+
try:
|
|
137
|
+
if file_path.endswith(".json"):
|
|
138
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
139
|
+
config = json.load(f)
|
|
140
|
+
else:
|
|
141
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
142
|
+
config = yaml.load(f, Loader=yaml.CLoader)
|
|
143
|
+
save_config_dict(config)
|
|
144
|
+
except Exception as e3:
|
|
145
|
+
QMessageBox.critical(parent, "Error", f"Failed to load selected config file: {e3}")
|
|
146
|
+
return
|
|
147
|
+
else:
|
|
148
|
+
QMessageBox.critical(parent, "Error", "No config file selected.")
|
|
149
|
+
return
|
|
150
|
+
else:
|
|
151
|
+
# User chose “No” → start with a blank config
|
|
152
|
+
config = {}
|
|
153
|
+
|
|
154
|
+
# Finally, open ConfigEditor on whichever config dict we have
|
|
155
|
+
editor = ConfigEditor(config, parent=parent)
|
|
156
|
+
if editor.exec() == QDialog.Accepted:
|
|
157
|
+
save_config_dict(config)
|
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from contextlib import ExitStack, redirect_stdout, redirect_stderr
|
|
19
|
+
|
|
20
|
+
from PySide6.QtWidgets import QMessageBox
|
|
21
|
+
from PySide6.QtGui import QTextCursor
|
|
22
|
+
|
|
23
|
+
from MIDRC_MELODY.common.data_loading import TestAndDemographicData, build_test_and_demographic_data
|
|
24
|
+
from MIDRC_MELODY.gui.data_loading import load_config_dict
|
|
25
|
+
from MIDRC_MELODY.gui.metrics_model import compute_qwk_metrics, compute_eod_aaod_metrics
|
|
26
|
+
from MIDRC_MELODY.gui.tqdm_handler import Worker, EmittingStream
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MainController:
|
|
30
|
+
def __init__(self, main_window):
|
|
31
|
+
self.main_window = main_window
|
|
32
|
+
|
|
33
|
+
def calculate_qwk(self):
|
|
34
|
+
"""
|
|
35
|
+
Triggered by the “QWK Metrics” toolbar button. Loads config, shows the progress view,
|
|
36
|
+
and spins up a Worker that captures print() → GUI and calls compute_qwk_metrics.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
config = load_config_dict()
|
|
40
|
+
except Exception as e:
|
|
41
|
+
QMessageBox.critical(self.main_window, "Error", f"Failed to load config:\n{e}")
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
# Ensure the “Progress Output” tab is visible before the Worker starts
|
|
45
|
+
self.main_window.show_progress_view()
|
|
46
|
+
|
|
47
|
+
# Create and start a Worker that wraps compute_qwk_metrics
|
|
48
|
+
worker = self._make_worker(
|
|
49
|
+
banner="QWK",
|
|
50
|
+
compute_fn=compute_qwk_metrics,
|
|
51
|
+
result_handler=self.main_window.update_qwk_tables,
|
|
52
|
+
config=config,
|
|
53
|
+
)
|
|
54
|
+
self.main_window.threadpool.start(worker)
|
|
55
|
+
|
|
56
|
+
def calculate_eod_aaod(self):
|
|
57
|
+
"""
|
|
58
|
+
Triggered by the “EOD/AAOD Metrics” toolbar button. Loads config, shows the progress view,
|
|
59
|
+
and spins up a Worker that captures print() → GUI and calls compute_eod_aaod_metrics.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
config = load_config_dict()
|
|
63
|
+
except Exception as e:
|
|
64
|
+
QMessageBox.critical(self.main_window, "Error", f"Failed to load config:\n{e}")
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
self.main_window.show_progress_view()
|
|
68
|
+
|
|
69
|
+
worker = self._make_worker(
|
|
70
|
+
banner="EOD/AAOD",
|
|
71
|
+
compute_fn=compute_eod_aaod_metrics,
|
|
72
|
+
result_handler=self.main_window.update_eod_aaod_tables,
|
|
73
|
+
config=config,
|
|
74
|
+
)
|
|
75
|
+
self.main_window.threadpool.start(worker)
|
|
76
|
+
|
|
77
|
+
def _make_worker(self, banner: str, compute_fn, result_handler, config: dict):
|
|
78
|
+
"""
|
|
79
|
+
Internal helper to create a Worker that:
|
|
80
|
+
1) Redirects stdout/stderr → EmittingStream → main_window.append_progress
|
|
81
|
+
2) Prints “Computing {banner} metrics…”
|
|
82
|
+
3) Calls the appropriate compute_fn (either compute_qwk_metrics or compute_eod_aaod_metrics)
|
|
83
|
+
4) Restores stdout/stderr and returns the result
|
|
84
|
+
|
|
85
|
+
Parameters:
|
|
86
|
+
- banner: "QWK" or "EOD/AAOD" (used in the printed banner and error dialogs)
|
|
87
|
+
- compute_fn: either compute_qwk_metrics or compute_eod_aaod_metrics
|
|
88
|
+
- result_handler: a slot on main_window (update_qwk_tables or update_eod_aaod_tables)
|
|
89
|
+
- config: the loaded configuration dict
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
- A Worker instance that, when started, will run _task(config) in a background thread.
|
|
93
|
+
"""
|
|
94
|
+
def _task(cfg):
|
|
95
|
+
# 1) Save original stdout/stderr so we can restore later
|
|
96
|
+
original_stdout = sys.stdout
|
|
97
|
+
original_stderr = sys.stderr
|
|
98
|
+
|
|
99
|
+
# 2) Build an EmittingStream and connect its textWritten → append_progress
|
|
100
|
+
stream = EmittingStream()
|
|
101
|
+
stream.textWritten.connect(self.main_window.append_progress)
|
|
102
|
+
|
|
103
|
+
# 3) Redirect stdout/stderr → EmittingStream
|
|
104
|
+
with ExitStack() as es:
|
|
105
|
+
es.enter_context(redirect_stdout(stream))
|
|
106
|
+
es.enter_context(redirect_stderr(stream))
|
|
107
|
+
|
|
108
|
+
# 4) Print the initial banner
|
|
109
|
+
time_start = time.time()
|
|
110
|
+
if not self.main_window.progress_view.document().isEmpty():
|
|
111
|
+
# Move cursor to the end of the progress view
|
|
112
|
+
self.main_window.progress_view.moveCursor(QTextCursor.End)
|
|
113
|
+
print('\n', '-'*140, '\n')
|
|
114
|
+
print(f"Computing {banner} metrics... "
|
|
115
|
+
f"(Started at {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time_start))})")
|
|
116
|
+
|
|
117
|
+
# 5) Build test data and run the compute function
|
|
118
|
+
test_data: TestAndDemographicData = build_test_and_demographic_data(cfg)
|
|
119
|
+
plot_config = cfg.get("plot", None)
|
|
120
|
+
if compute_fn is compute_eod_aaod_metrics:
|
|
121
|
+
# EOD/AAOD needs a threshold from cfg (default to 0.5)
|
|
122
|
+
threshold = cfg.get("binary threshold", 0.5)
|
|
123
|
+
result = compute_fn(test_data, threshold, plot_config)
|
|
124
|
+
else:
|
|
125
|
+
# QWK just takes test_data
|
|
126
|
+
result = compute_fn(test_data, plot_config)
|
|
127
|
+
|
|
128
|
+
print(f"Finished {banner} metrics in {time.time() - time_start:.2f} seconds.")
|
|
129
|
+
|
|
130
|
+
# 6) Restore original stdout/stderr so further print() goes to console
|
|
131
|
+
sys.stdout = original_stdout
|
|
132
|
+
sys.stderr = original_stderr
|
|
133
|
+
|
|
134
|
+
# 7) Return the computed result, e.g.
|
|
135
|
+
# - For QWK: (all_rows, filtered_rows, kappas_rows, plot_args), reference_groups
|
|
136
|
+
# - For EOD/AAOD: (all_eod_rows, all_aaod_rows, filtered_rows, plot_args), reference_groups
|
|
137
|
+
return result, test_data.reference_groups
|
|
138
|
+
|
|
139
|
+
# Instantiate the Worker around our _task function + config dict
|
|
140
|
+
worker = Worker(_task, config)
|
|
141
|
+
|
|
142
|
+
# Connect the result signal to the appropriate table‐update slot
|
|
143
|
+
worker.signals.result.connect(result_handler)
|
|
144
|
+
|
|
145
|
+
# Connect any error to a QMessageBox
|
|
146
|
+
worker.signals.error.connect(
|
|
147
|
+
lambda e: QMessageBox.critical(
|
|
148
|
+
self.main_window,
|
|
149
|
+
"Error",
|
|
150
|
+
f"Error in {banner} Metrics:\n{e}"
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return worker
|