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,210 @@
|
|
|
1
|
+
"""ANSI escape sequence processor and worker/thread helpers for GUI."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Final, Optional
|
|
5
|
+
|
|
6
|
+
from PySide6.QtCore import QObject, QRunnable, Signal
|
|
7
|
+
from PySide6.QtGui import QFont, QTextCharFormat, QTextCursor
|
|
8
|
+
from PySide6.QtWidgets import QPlainTextEdit
|
|
9
|
+
|
|
10
|
+
__all__ = ["ANSIProcessor", "EmittingStream", "Worker"]
|
|
11
|
+
|
|
12
|
+
_CSI_RE: Final[re.Pattern] = re.compile(r"\x1b\[(\d*)([A-Za-z])")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _next_csi_is_cursor_up(buf: str, pos: int) -> bool:
|
|
16
|
+
"""Return True if next CSI sequence at pos is 'cursor up' (A)."""
|
|
17
|
+
if pos >= len(buf) or buf[pos] != "\x1b":
|
|
18
|
+
return False
|
|
19
|
+
match = _CSI_RE.match(buf, pos)
|
|
20
|
+
return bool(match and match.group(2) == "A")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ANSIProcessor:
|
|
24
|
+
"""Process ANSI control sequences and render them in a QPlainTextEdit."""
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def process(console: QPlainTextEdit, chunk: str) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Parse chunk for ANSI escapes, updating the console widget.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
console: Target QPlainTextEdit.
|
|
33
|
+
chunk: Text with possible ANSI sequences.
|
|
34
|
+
"""
|
|
35
|
+
ANSIProcessor._ensure_console_flags(console)
|
|
36
|
+
cursor = console.textCursor()
|
|
37
|
+
i, n = 0, len(chunk)
|
|
38
|
+
text_buf: list[str] = []
|
|
39
|
+
|
|
40
|
+
while i < n:
|
|
41
|
+
if console._nl_pending: # type: ignore[attr-defined]
|
|
42
|
+
ANSIProcessor._commit_pending_newline(console, cursor, chunk, i)
|
|
43
|
+
|
|
44
|
+
ch = chunk[i]
|
|
45
|
+
if ch == "\r": # Handle carriage return
|
|
46
|
+
if text_buf:
|
|
47
|
+
cursor.insertText("".join(text_buf))
|
|
48
|
+
text_buf.clear()
|
|
49
|
+
ANSIProcessor._handle_carriage_return(cursor)
|
|
50
|
+
i += 1
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
if ch == "\n": # Handle line feed
|
|
54
|
+
if text_buf:
|
|
55
|
+
cursor.insertText("".join(text_buf))
|
|
56
|
+
text_buf.clear()
|
|
57
|
+
ANSIProcessor._handle_line_feed(cursor)
|
|
58
|
+
i += 1
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
if ch == "\x1b": # Handle CSI (Control Sequence Introducer) sequence
|
|
62
|
+
if text_buf:
|
|
63
|
+
cursor.insertText("".join(text_buf))
|
|
64
|
+
text_buf.clear()
|
|
65
|
+
new_pos = ANSIProcessor._handle_csi(console, cursor, chunk, i)
|
|
66
|
+
if new_pos is not None:
|
|
67
|
+
i = new_pos
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
text_buf.append(ch)
|
|
71
|
+
i += 1
|
|
72
|
+
|
|
73
|
+
if text_buf:
|
|
74
|
+
cursor.insertText("".join(text_buf))
|
|
75
|
+
|
|
76
|
+
console.setTextCursor(cursor)
|
|
77
|
+
console.ensureCursorVisible()
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _ensure_console_flags(console: QPlainTextEdit) -> None:
|
|
81
|
+
"""Initialize internal flags for ANSI processing on the widget."""
|
|
82
|
+
if not hasattr(console, "_nl_pending"):
|
|
83
|
+
console._nl_pending = False # type: ignore[attr-defined]
|
|
84
|
+
if not hasattr(console, "_ansi_fmt"):
|
|
85
|
+
console._ansi_fmt = QTextCharFormat() # type: ignore[attr-defined]
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _commit_pending_newline(
|
|
89
|
+
console: QPlainTextEdit, cursor: QTextCursor, buf: str, pos: int
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Insert a newline unless the next sequence is cursor-up."""
|
|
92
|
+
if not _next_csi_is_cursor_up(buf, pos):
|
|
93
|
+
cursor.insertBlock()
|
|
94
|
+
console._nl_pending = False # type: ignore[attr-defined]
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _handle_carriage_return(cursor: QTextCursor) -> None:
|
|
98
|
+
"""Handle '\\r': move to start of line and clear it."""
|
|
99
|
+
cursor.movePosition(QTextCursor.StartOfLine)
|
|
100
|
+
cursor.select(QTextCursor.LineUnderCursor)
|
|
101
|
+
cursor.removeSelectedText()
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _handle_line_feed(cursor: QTextCursor) -> None:
|
|
105
|
+
"""Handle '\\n': move down or insert a new block then start of line."""
|
|
106
|
+
if not cursor.movePosition(QTextCursor.Down):
|
|
107
|
+
cursor.insertBlock()
|
|
108
|
+
cursor.movePosition(QTextCursor.StartOfLine)
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _handle_csi(
|
|
112
|
+
console: QPlainTextEdit,
|
|
113
|
+
cursor: QTextCursor,
|
|
114
|
+
buf: str,
|
|
115
|
+
pos: int,
|
|
116
|
+
) -> Optional[int]:
|
|
117
|
+
"""
|
|
118
|
+
Process CSI (Control Sequence Introducer) sequences.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Position after CSI if handled, otherwise None.
|
|
122
|
+
"""
|
|
123
|
+
match = _CSI_RE.match(buf, pos)
|
|
124
|
+
if not match:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
count = int(match.group(1) or 1)
|
|
128
|
+
cmd = match.group(2)
|
|
129
|
+
|
|
130
|
+
if cmd == "A": # Cursor Up
|
|
131
|
+
for _ in range(count):
|
|
132
|
+
cursor.movePosition(QTextCursor.Up)
|
|
133
|
+
cursor.movePosition(QTextCursor.StartOfLine)
|
|
134
|
+
return match.end()
|
|
135
|
+
|
|
136
|
+
if cmd == "K": # Erase Line
|
|
137
|
+
cursor.select(QTextCursor.LineUnderCursor)
|
|
138
|
+
cursor.removeSelectedText()
|
|
139
|
+
cursor.movePosition(QTextCursor.StartOfLine)
|
|
140
|
+
return match.end()
|
|
141
|
+
|
|
142
|
+
if cmd == "m": # Set Graphics Rendition (text style: e.g. bold, reset, etc.)
|
|
143
|
+
for part in (match.group(1) or "0").split(";"):
|
|
144
|
+
style = part or "0"
|
|
145
|
+
if style == "0":
|
|
146
|
+
console._ansi_fmt = QTextCharFormat() # type: ignore[attr-defined]
|
|
147
|
+
elif style == "1":
|
|
148
|
+
console._ansi_fmt.setFontWeight(QFont.Bold)
|
|
149
|
+
elif style == "22":
|
|
150
|
+
console._ansi_fmt.setFontWeight(QFont.Normal)
|
|
151
|
+
cursor.setCharFormat(console._ansi_fmt)
|
|
152
|
+
return match.end()
|
|
153
|
+
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class EmittingStream(QObject):
|
|
158
|
+
"""A text stream that emits data via Qt signals."""
|
|
159
|
+
|
|
160
|
+
textWritten: Signal = Signal(str)
|
|
161
|
+
|
|
162
|
+
def write(self, data: str) -> None:
|
|
163
|
+
"""Emit chunk of text written to this stream."""
|
|
164
|
+
text = str(data)
|
|
165
|
+
if text:
|
|
166
|
+
self.textWritten.emit(text)
|
|
167
|
+
|
|
168
|
+
def flush(self) -> None:
|
|
169
|
+
"""No-op flush to satisfy stream interface."""
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
def isatty(self) -> bool:
|
|
173
|
+
"""Indicate this stream behaves like a terminal."""
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class WorkerSignals(QObject):
|
|
178
|
+
"""Signals for Worker: finished, error, and result."""
|
|
179
|
+
finished: Signal = Signal()
|
|
180
|
+
error: Signal = Signal(str)
|
|
181
|
+
result: Signal = Signal(object)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class Worker(QRunnable):
|
|
185
|
+
"""
|
|
186
|
+
QRunnable wrapper to execute a function in a separate thread.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
fn: Callable to run.
|
|
190
|
+
*args: Positional args for fn.
|
|
191
|
+
**kwargs: Keyword args for fn.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
def __init__(self, fn, *args, **kwargs) -> None:
|
|
195
|
+
super().__init__()
|
|
196
|
+
self.fn = fn
|
|
197
|
+
self.args = args
|
|
198
|
+
self.kwargs = kwargs
|
|
199
|
+
self.signals = WorkerSignals()
|
|
200
|
+
|
|
201
|
+
def run(self) -> None:
|
|
202
|
+
"""Execute the function and emit result, error, and finished signals."""
|
|
203
|
+
try:
|
|
204
|
+
result = self.fn(*self.args, **self.kwargs)
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
self.signals.error.emit(str(exc))
|
|
207
|
+
else:
|
|
208
|
+
self.signals.result.emit(result)
|
|
209
|
+
finally:
|
|
210
|
+
self.signals.finished.emit()
|
MIDRC_MELODY/melody.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
|
|
18
|
+
from MIDRC_MELODY.common.edit_config import edit_config
|
|
19
|
+
from MIDRC_MELODY.common.generate_eod_aaod_spiders import generate_eod_aaod_spiders
|
|
20
|
+
from MIDRC_MELODY.common.generate_qwk_spiders import generate_qwk_spiders
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from PySide6.QtWidgets import QWidget # noqa: F401
|
|
24
|
+
GUI_AVAILABLE = True
|
|
25
|
+
except ImportError:
|
|
26
|
+
GUI_AVAILABLE = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def show_config(path):
|
|
30
|
+
"""Print out the YAML config file at `path`."""
|
|
31
|
+
try:
|
|
32
|
+
print(open(path, encoding='utf-8').read())
|
|
33
|
+
except Exception as e:
|
|
34
|
+
print(f"Error reading config: {e}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _launch_gui():
|
|
38
|
+
"""Launch the GUI if available."""
|
|
39
|
+
try:
|
|
40
|
+
from MIDRC_MELODY.melody_gui import launch_gui
|
|
41
|
+
except ImportError as e1:
|
|
42
|
+
try:
|
|
43
|
+
from melody_gui import launch_gui
|
|
44
|
+
except ImportError as e2:
|
|
45
|
+
e = e2 if str(e1) == "No module named 'MIDRC_MELODY.melody_gui'" else (
|
|
46
|
+
str(e1) + "\n" + str(e2) if str(e2) != "No module named 'melody_gui'" else e1)
|
|
47
|
+
print('-' * 50)
|
|
48
|
+
print(f"GUI is not available, import failed with error:\n{e}")
|
|
49
|
+
print('-' * 50)
|
|
50
|
+
return
|
|
51
|
+
launch_gui()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def set_config(current_path):
|
|
55
|
+
"""Prompt for a new path and return it (or the old one)."""
|
|
56
|
+
new = input(f"Enter new config path [{current_path}]: ").strip()
|
|
57
|
+
return new or current_path
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def quit_program(_=None):
|
|
61
|
+
print("Goodbye!")
|
|
62
|
+
sys.exit(0)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main():
|
|
66
|
+
# wrap your mutable state in a dict
|
|
67
|
+
state = {"config_path": "config.yaml"}
|
|
68
|
+
|
|
69
|
+
commands = {
|
|
70
|
+
"1": ("Calculate QWK metrics",
|
|
71
|
+
lambda: generate_qwk_spiders(cfg_path=state["config_path"])),
|
|
72
|
+
"2": ("Calculate EOD/AAOD metrics",
|
|
73
|
+
lambda: generate_eod_aaod_spiders(cfg_path=state["config_path"])),
|
|
74
|
+
}
|
|
75
|
+
if GUI_AVAILABLE:
|
|
76
|
+
commands["3"] = ("Launch GUI", lambda: _launch_gui())
|
|
77
|
+
commands["s"] = ("Show current config file contents", lambda: show_config(state["config_path"]))
|
|
78
|
+
commands["f"] = ("Change config file path", lambda: state.update(config_path=set_config(state["config_path"])))
|
|
79
|
+
commands["e"] = ("Edit config file", lambda: edit_config(state["config_path"]))
|
|
80
|
+
commands["q"] = ("Quit", quit_program)
|
|
81
|
+
|
|
82
|
+
BOLD = '\033[1m'
|
|
83
|
+
RESET = '\033[0m'
|
|
84
|
+
while True:
|
|
85
|
+
print("\n=== MIDRC‑MELODY Menu ===")
|
|
86
|
+
print(f"Current config file path: {BOLD}{state['config_path']}{RESET}")
|
|
87
|
+
for key, (desc, _) in commands.items():
|
|
88
|
+
print(f" {key}) {desc}")
|
|
89
|
+
choice = input("Select an option: ").strip()
|
|
90
|
+
|
|
91
|
+
action = commands.get(choice)
|
|
92
|
+
if not action:
|
|
93
|
+
print(f" ❌ '{choice}' is not a valid choice.")
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
# call the handler
|
|
97
|
+
_, handler = action
|
|
98
|
+
handler()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
main()
|
|
@@ -0,0 +1,111 @@
|
|
|
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 importlib import resources as _resources
|
|
17
|
+
import sys
|
|
18
|
+
try:
|
|
19
|
+
from PySide6.QtWidgets import QApplication
|
|
20
|
+
from PySide6.QtCore import QTimer, Qt
|
|
21
|
+
from PySide6.QtGui import QIcon, QPixmap
|
|
22
|
+
from MIDRC_MELODY.gui.main_window import MainWindow # Import the custom MainWindow
|
|
23
|
+
except ImportError as e:
|
|
24
|
+
raise ImportError("To use the GUI features, please install the package PySide6.\n"
|
|
25
|
+
"You can install it using the command:\n"
|
|
26
|
+
"pip install PySide6\n"
|
|
27
|
+
"\n"
|
|
28
|
+
"Error details: " + str(e))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Global list to hold window references.
|
|
32
|
+
_open_windows = []
|
|
33
|
+
|
|
34
|
+
def _load_package_icon() -> QIcon:
|
|
35
|
+
"""
|
|
36
|
+
Load the bundled icon from the installed package resources.
|
|
37
|
+
Uses importlib.resources.files(...) with the current package name.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
pkg = __package__ or "MIDRC_MELODY"
|
|
41
|
+
data = _resources.files(pkg).joinpath("resources", "MIDRC.ico").read_bytes()
|
|
42
|
+
pix = QPixmap()
|
|
43
|
+
pix.loadFromData(data)
|
|
44
|
+
return QIcon(pix)
|
|
45
|
+
except Exception:
|
|
46
|
+
return QIcon()
|
|
47
|
+
|
|
48
|
+
# optional: import ctypes only on Windows
|
|
49
|
+
_ctypes = None
|
|
50
|
+
if sys.platform == "win32":
|
|
51
|
+
try:
|
|
52
|
+
import ctypes as _ctypes
|
|
53
|
+
except Exception:
|
|
54
|
+
_ctypes = None
|
|
55
|
+
|
|
56
|
+
def _set_windows_appid(appid: str) -> None:
|
|
57
|
+
"""
|
|
58
|
+
On Windows set an explicit AppUserModelID so the taskbar uses the correct icon.
|
|
59
|
+
Must be called before creating any windows / QApplication.
|
|
60
|
+
"""
|
|
61
|
+
if sys.platform != "win32" or _ctypes is None:
|
|
62
|
+
return
|
|
63
|
+
try:
|
|
64
|
+
_ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid)
|
|
65
|
+
except Exception:
|
|
66
|
+
# best-effort; ignore failures
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
def launch_gui() -> None:
|
|
70
|
+
_set_windows_appid("com.midrc.melody")
|
|
71
|
+
app = QApplication.instance()
|
|
72
|
+
if app is None:
|
|
73
|
+
app = QApplication([])
|
|
74
|
+
new_app = True
|
|
75
|
+
else:
|
|
76
|
+
new_app = False
|
|
77
|
+
|
|
78
|
+
icon = _load_package_icon()
|
|
79
|
+
if not icon.isNull():
|
|
80
|
+
app.setWindowIcon(icon)
|
|
81
|
+
|
|
82
|
+
window = MainWindow()
|
|
83
|
+
if not icon.isNull():
|
|
84
|
+
window.setWindowIcon(icon)
|
|
85
|
+
_open_windows.clear()
|
|
86
|
+
_open_windows.append(window)
|
|
87
|
+
|
|
88
|
+
# Store original window flags
|
|
89
|
+
original_flags = window.windowFlags()
|
|
90
|
+
|
|
91
|
+
window.show()
|
|
92
|
+
# Temporarily add the always-on-top flag without losing other flags.
|
|
93
|
+
QTimer.singleShot(100, lambda: (
|
|
94
|
+
window.setWindowFlags(original_flags | Qt.WindowStaysOnTopHint),
|
|
95
|
+
window.show()
|
|
96
|
+
))
|
|
97
|
+
# After a short delay, restore the original flags with the window still in the front.
|
|
98
|
+
QTimer.singleShot(300, lambda: (
|
|
99
|
+
window.setWindowFlags(original_flags),
|
|
100
|
+
window.show()
|
|
101
|
+
))
|
|
102
|
+
if new_app:
|
|
103
|
+
app.exec()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main():
|
|
107
|
+
launch_gui()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
main()
|
|
Binary file
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: MIDRC_MELODY
|
|
3
|
+
Version: 0.3.3
|
|
4
|
+
Summary: MELODY: Model EvaLuation across subgroups for cOnsistent Decision accuracY
|
|
5
|
+
Author-email: Robert Tomek <rtomek@uchicago.edu>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/MIDRC/MIDRC_MELODY
|
|
8
|
+
Project-URL: Issues, https://github.com/MIDRC/MIDRC_MELODY/issues
|
|
9
|
+
Keywords: machine-learning,model-evaluation,fairness,subgroup-analysis
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: pandas
|
|
13
|
+
Requires-Dist: matplotlib
|
|
14
|
+
Requires-Dist: numpy
|
|
15
|
+
Requires-Dist: scikit-learn
|
|
16
|
+
Requires-Dist: PyYAML
|
|
17
|
+
Requires-Dist: joblib
|
|
18
|
+
Requires-Dist: tqdm
|
|
19
|
+
Requires-Dist: tabulate
|
|
20
|
+
Requires-Dist: tqdm-joblib
|
|
21
|
+
Requires-Dist: mplcursors
|
|
22
|
+
Requires-Dist: windows-curses; sys_platform == "win32"
|
|
23
|
+
Requires-Dist: PySide6>=6.8.0
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# MIDRC MELODY (Model EvaLuation across subgroups for cOnsistent Decision accuracY)
|
|
27
|
+
|
|
28
|
+
[Overview](#overview) | [Installation](#installation) | [CLI Commands](#cli-commands) | [Configuration](#configuration) | [License](#license)
|
|
29
|
+
|
|
30
|
+
[📱 Visit MIDRC Website](https://www.midrc.org/)
|
|
31
|
+
|
|
32
|
+
**MIDRC MELODY** is a tool designed to assess the performance and subgroup-level reliability and robustness of AI models
|
|
33
|
+
developed for medical imaging analysis tasks, such as the estimation of disease severity. It enables consistent
|
|
34
|
+
evaluation of models across predefined subgroups (e.g. manufacturer, race, scanner type) by computing intergroup
|
|
35
|
+
performance metrics and corresponding confidence intervals.
|
|
36
|
+
|
|
37
|
+
The tool supports two types of evaluation:
|
|
38
|
+
|
|
39
|
+
- **Ordinal Estimation Task Evaluation**:
|
|
40
|
+
- Uses an ordinal reference standard ("truth") and ordinal AI model outputs.
|
|
41
|
+
- Performance in terms of agreement of AI output with the reference standard "truth" is quantified using the **quadratic
|
|
42
|
+
weighted kappa (QWK)** metric.
|
|
43
|
+
- **Binary Decision Task Evaluation**:
|
|
44
|
+
- Converts scores into binary decisions based on a threshold.
|
|
45
|
+
- Computes **Equal Opportunity Difference (EOD)** and **Average Absolute Odds Difference (AAOD)** metrics using bootstrapping across various groups.
|
|
46
|
+
- Generates spider plots comparing these metrics.
|
|
47
|
+
- Saves the generated data for further analysis.
|
|
48
|
+
|
|
49
|
+
### Data Processing and Visualization
|
|
50
|
+
|
|
51
|
+
- **Bootstrapping:** Both scripts perform bootstrapping to compute confidence intervals for the respective metrics using NumPy's percentile method.
|
|
52
|
+
- **Plotting:** Spider charts provide a visual overview of how each model's metrics vary across different groups and categories.
|
|
53
|
+
- **Utilities:** Shared functionality is available in common utility modules (e.g., `data_tools.py` and `plot_tools.py`), ensuring easier maintenance and testing.
|
|
54
|
+
|
|
55
|
+
## Overview
|
|
56
|
+
|
|
57
|
+
**MIDRC MELODY** is a lightweight toolkit for stress‑testing medical‑imaging AI models across clinical and demographic sub‑groups. It supports both command‑line and GUI workflows, enabling rapid quantification of performance disparities (QWK, EOD, AAOD, etc.) and intuitive radar‑chart visualisation.
|
|
58
|
+
|
|
59
|
+
- **Console‑first** – core metrics and plots run with **no GUI dependencies**.
|
|
60
|
+
- **Opt‑in GUI** – an optional PySide6 interface for interactive configuration and result browsing.
|
|
61
|
+
- **Config‑driven** – YAML files keep experiments reproducible and shareable.
|
|
62
|
+
|
|
63
|
+
## Installation
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# Standard console install from PyPI
|
|
67
|
+
pip install midrc-melody
|
|
68
|
+
|
|
69
|
+
# (Alternative) Install in editable/development mode from source code
|
|
70
|
+
pip install -e .
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Quick Start
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# Run analysis (reads default config.yaml in current directory)
|
|
77
|
+
melody
|
|
78
|
+
|
|
79
|
+
# Launch the GUI
|
|
80
|
+
melody_gui
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## CLI Commands
|
|
84
|
+
|
|
85
|
+
Running `melody` opens a **Command‑Line Interface (CLI)**, which presents a text‑based menu of interactive commands. Here’s what you can do:
|
|
86
|
+
|
|
87
|
+
#### Available Commands
|
|
88
|
+
|
|
89
|
+
- **Calculate QWK metrics**: Computes delta QWK values for different subgroups and generates spider plots.
|
|
90
|
+
- **Calculate EOD and AAOD metrics**: Computes EOD and AAOD metrics for binary decision tasks and generates spider plots.
|
|
91
|
+
- **Print config file contents**: Displays the contents of the current YAML configuration file.
|
|
92
|
+
- **Change config file**: Prompts you to enter and set a different configuration file path.
|
|
93
|
+
- **Launch GUI**: Opens the Graphical User Interface (GUI) using PySide6 (requires PySide6).
|
|
94
|
+
- **Exit**: Exits the program.
|
|
95
|
+
|
|
96
|
+
## GUI (Optional)
|
|
97
|
+
|
|
98
|
+
Launching the graphical interface only requires that PySide6 is installed. If you used pip, the `melody_gui` command is available.
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
# Launch the GUI:
|
|
102
|
+
melody_gui
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Configuration
|
|
106
|
+
|
|
107
|
+
Experiments are described in a single YAML file. Below is a **minimal** example that keeps storage light and avoids plotting custom order metadata.
|
|
108
|
+
|
|
109
|
+
```yaml
|
|
110
|
+
input data:
|
|
111
|
+
truth file: data/demo_truthNdemographics.csv
|
|
112
|
+
test scores: data/demo_scores.csv
|
|
113
|
+
uid column: case_name
|
|
114
|
+
truth column: truth
|
|
115
|
+
|
|
116
|
+
# Scores ≥ binary threshold are counted as positive
|
|
117
|
+
binary threshold: 4
|
|
118
|
+
min count per category: 10
|
|
119
|
+
|
|
120
|
+
bootstrap:
|
|
121
|
+
iterations: 1000
|
|
122
|
+
seed: 42 # set to null for random entropy
|
|
123
|
+
|
|
124
|
+
output:
|
|
125
|
+
qwk: { save: false, file prefix: output/delta_kappas_ }
|
|
126
|
+
eod: { save: false, file prefix: output/eod_ }
|
|
127
|
+
aaod: { save: false, file prefix: output/aaod_ }
|
|
128
|
+
|
|
129
|
+
numeric_cols:
|
|
130
|
+
age_binned:
|
|
131
|
+
raw column: age
|
|
132
|
+
bins: [0, 18, 30, 40, 50, 65, 75, 85, .inf]
|
|
133
|
+
|
|
134
|
+
plot:
|
|
135
|
+
clockwise: true # rotate clockwise instead of CCW
|
|
136
|
+
start: top # starting angle: top, bottom, left, right (t/b/l/r)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Input Data
|
|
140
|
+
|
|
141
|
+
| File | Required Columns | Purpose | Example |
|
|
142
|
+
| -------------- | --------------------------- | ----------------------------------------- |----------------------------------------------------|
|
|
143
|
+
| **Truth file** | `uid`, `truth`, attributes… | Ground‑truth labels and subgroup columns. | [demo_truth.csv](data/demo_truthNdemographics.csv) |
|
|
144
|
+
| **Score file** | `uid`, `score` | Model predictions keyed to the same UID. | [demo_scores.csv](data/demo_scores.csv) |
|
|
145
|
+
|
|
146
|
+
> UID values must match between truth and score files.
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
Distributed under the Apache 2.0 License.
|
|
151
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
MIDRC_MELODY/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
MIDRC_MELODY/__main__.py,sha256=0iJVMq84JaIZHWWBVb2Ly_ea1diOW3V6wSAoZbABDZs,67
|
|
3
|
+
MIDRC_MELODY/melody.py,sha256=5K8-mROTlG2Bm2qUGYflTCf9aErW5RyfHHrbGWbq_wA,3564
|
|
4
|
+
MIDRC_MELODY/melody_gui.py,sha256=IIN9Lz_7ADoeaOzZGiXSifVnNyPKkv_Ak1lPPZkSTxY,3559
|
|
5
|
+
MIDRC_MELODY/common/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
MIDRC_MELODY/common/data_loading.py,sha256=MRX0B3aw9Y8x94pSWcHF-RHneoz4HGwLIQJ_BhLbcak,7648
|
|
7
|
+
MIDRC_MELODY/common/data_preprocessing.py,sha256=oKpfl14-nM_X9u6Rkpp6kKMGSK12zt4Cvd-rn-pxzz0,5291
|
|
8
|
+
MIDRC_MELODY/common/edit_config.py,sha256=q6gZiy-sA6sY9bD_tP4w1fwpFls4zHDi1_YPegoD17U,4740
|
|
9
|
+
MIDRC_MELODY/common/eod_aaod_metrics.py,sha256=nCJ9g1kMKA5NY-JasI3kITozsbF_XdVZcv8NOD-DNmI,11733
|
|
10
|
+
MIDRC_MELODY/common/generate_eod_aaod_spiders.py,sha256=qe4WkUA_j50yjzW65orWyfBuHeZG9ULcuuJ3cq3ei0A,2954
|
|
11
|
+
MIDRC_MELODY/common/generate_qwk_spiders.py,sha256=MRplmYU3N5AX-p8FBOVg3jI8OMo8RSwql5Sgiq-t8hI,2252
|
|
12
|
+
MIDRC_MELODY/common/matplotlib_spider.py,sha256=G7ILK3hS7CcxJUXtYGSXTJKyIzyh3qbLbh-jNlSJ7BM,15478
|
|
13
|
+
MIDRC_MELODY/common/plot_tools.py,sha256=TV3az3geV7Y3w8DszjP0umnUqLqfc6jg_rhl_6DyAmA,4874
|
|
14
|
+
MIDRC_MELODY/common/plotly_spider.py,sha256=asUki0EeWwonJCQhCZrC2pIquIM7HJc9oGcUeBmjuUA,9218
|
|
15
|
+
MIDRC_MELODY/common/qwk_metrics.py,sha256=yucVOumytQS_CvndqRpkuSs_LaRU71zTWkhZRGHT90M,10379
|
|
16
|
+
MIDRC_MELODY/common/table_tools.py,sha256=zMVmOQs7U4TDiyhHF1e_GYHYvmKxypSrgu6gyikLItk,9251
|
|
17
|
+
MIDRC_MELODY/gui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
MIDRC_MELODY/gui/config_editor.py,sha256=q_NNBnj4VAvCQ6-KINBe5mhUVTHKbibK7kUTvXMjkXk,9420
|
|
19
|
+
MIDRC_MELODY/gui/data_loading.py,sha256=AfAFfle0lMrE4BOfepzy2aSEM5k4oT279G5xFi1-Z7U,6456
|
|
20
|
+
MIDRC_MELODY/gui/main_controller.py,sha256=4ZJPAzzq3TE2cKy8UlkniPV6VKBf6qMVkBZNk4HZu2c,6809
|
|
21
|
+
MIDRC_MELODY/gui/main_window.py,sha256=1wF6qw8m_C7l3i1nGGUO9pyV9a3v4oNLVkhTYhA1Cxc,20191
|
|
22
|
+
MIDRC_MELODY/gui/matplotlib_spider_widget.py,sha256=yxuVPIPEtWXqmCacfsG6XexNLST1gd1ykD_BpJygMC4,8185
|
|
23
|
+
MIDRC_MELODY/gui/metrics_model.py,sha256=bVlQSrh8JQJUGCXgoO8Vm6Hbf-W1fKjvtcuyYTafWjQ,3046
|
|
24
|
+
MIDRC_MELODY/gui/plotly_spider_widget.py,sha256=SQOEATppxVfxrHTFkjD-d5F6cKvxtfBCsDbIP7RxLO8,2193
|
|
25
|
+
MIDRC_MELODY/gui/qchart_spider_widget.py,sha256=iBXc-w9So2re2aFFszqMV_BFcU18tV6JpO5NBo4f8Cs,10437
|
|
26
|
+
MIDRC_MELODY/gui/tqdm_handler.py,sha256=drMVl9c5Rc6rPogD0pMaeFvyhHTXcqtUUU2I-W6D318,7143
|
|
27
|
+
MIDRC_MELODY/gui/shared/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
28
|
+
MIDRC_MELODY/gui/shared/react/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
29
|
+
MIDRC_MELODY/gui/shared/react/copyabletableview.py,sha256=jn_9RvlX2uU8Ib6KRCqmHGbnmtvU3Hu8DrUWuimgyGU,3668
|
|
30
|
+
MIDRC_MELODY/gui/shared/react/grabbablewidget.py,sha256=a7Z736TmtShki8nKSWVzTlaw53-TqxIRN1r3OyOr1K4,15569
|
|
31
|
+
MIDRC_MELODY/resources/MIDRC.ico,sha256=xvcK2nPPuOdqNsxBNdRxOKuXbCQ6J-cTuxDKQ6yX-qI,4022
|
|
32
|
+
midrc_melody-0.3.3.dist-info/licenses/LICENSE,sha256=HrhfyXIkWY2tGFK11kg7vPCqhgh5DcxleloqdhrpyMY,11558
|
|
33
|
+
midrc_melody-0.3.3.dist-info/METADATA,sha256=WEGNWJokukCKF8weu88AYv5hfvYf87F0yOoe-geU8_o,6289
|
|
34
|
+
midrc_melody-0.3.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
35
|
+
midrc_melody-0.3.3.dist-info/entry_points.txt,sha256=O9uD1ZfwtlU7C9lAMiWEbh-19GGgCOw83cMqO-9iF5E,136
|
|
36
|
+
midrc_melody-0.3.3.dist-info/top_level.txt,sha256=2lK9hz0vJgDTCknUwOlanlf38tRV5RrDQVFwHxwjqQ0,13
|
|
37
|
+
midrc_melody-0.3.3.dist-info/RECORD,,
|